From 8818ea89da6526f8c0df131f92043692e386c91f Mon Sep 17 00:00:00 2001 From: Ilayaperumal Gopinathan Date: Thu, 20 Feb 2025 23:26:01 +0000 Subject: [PATCH] Modularise Spring AI Spring Boot autoconfigurations - Split spring-ai-spring-boot-autoconfigure into modules - This PR addresses the restructuring of the following spring boot autoconfigurations: - spring-ai retry -> common - spring-ai chat client/model/memory -> chat - spring-ai chat/embedding/image observation -> observation - spring-ai chat/embedding models -> models - Update the Spring AI BOM and boot starters with the new autoconfigure modules Signed-off-by: Ilayaperumal Gopinathan --- .../pom.xml | 66 ++ .../client/ChatClientAutoConfiguration.java | 92 +++ .../client/ChatClientBuilderConfigurer.java | 60 ++ .../client/ChatClientBuilderProperties.java | 71 ++ ...ot.autoconfigure.AutoConfiguration.imports | 16 + ...ientObservationAutoConfigurationTests.java | 49 ++ .../pom.xml | 98 +++ .../memory/CommonChatMemoryProperties.java | 37 + .../CassandraChatMemoryAutoConfiguration.java | 63 ++ .../CassandraChatMemoryProperties.java | 94 +++ ...ot.autoconfigure.AutoConfiguration.imports | 16 + ...assandraChatMemoryAutoConfigurationIT.java | 124 ++++ .../CassandraChatMemoryPropertiesTest.java | 64 ++ .../pom.xml | 66 ++ .../model/ToolCallingAutoConfiguration.java | 87 +++ ...ot.autoconfigure.AutoConfiguration.imports | 16 + .../ToolCallingAutoConfigurationTests.java | 193 +++++ .../pom.xml | 66 ++ .../retry/SpringAiRetryAutoConfiguration.java | 114 +++ .../retry/SpringAiRetryProperties.java | 147 ++++ ...ot.autoconfigure.AutoConfiguration.imports | 16 + .../SpringAiRetryAutoConfigurationIT.java | 45 ++ .../retry/SpringAiRetryPropertiesTests.java | 78 ++ .../{ => mcp}/spring-ai-mcp-client/pom.xml | 2 +- .../client/McpClientAutoConfiguration.java | 0 .../mcp/client/NamedClientMcpTransport.java | 0 ...eHttpClientTransportAutoConfiguration.java | 0 .../SseWebFluxTransportAutoConfiguration.java | 0 .../StdioTransportAutoConfiguration.java | 0 .../configurer/McpAsyncClientConfigurer.java | 0 .../configurer/McpSyncClientConfigurer.java | 0 .../properties/McpClientCommonProperties.java | 0 .../properties/McpSseClientProperties.java | 0 .../properties/McpStdioClientProperties.java | 0 ...ot.autoconfigure.AutoConfiguration.imports | 0 .../client/McpClientAutoConfigurationIT.java | 0 .../resources/application-test.properties | 0 .../{ => mcp}/spring-ai-mcp-server/pom.xml | 2 +- .../server/McpServerAutoConfiguration.java | 0 .../mcp/server/McpServerProperties.java | 0 .../McpWebFluxServerAutoConfiguration.java | 0 .../McpWebMvcServerAutoConfiguration.java | 0 ...ot.autoconfigure.AutoConfiguration.imports | 0 .../server/McpServerAutoConfigurationIT.java | 0 .../pom.xml | 93 +++ .../anthropic/AnthropicAutoConfiguration.java | 102 +++ .../anthropic/AnthropicChatProperties.java | 65 ++ .../AnthropicConnectionProperties.java | 86 +++ ...ot.autoconfigure.AutoConfiguration.imports | 16 + .../AnthropicAutoConfigurationIT.java | 94 +++ .../anthropic/AnthropicPropertiesTests.java | 132 ++++ .../tool/FunctionCallWithFunctionBeanIT.java | 126 ++++ .../FunctionCallWithPromptFunctionIT.java | 76 ++ .../anthropic/tool/MockWeatherService.java | 95 +++ .../pom.xml | 93 +++ .../AzureOpenAIClientBuilderCustomizer.java | 21 + ...ureOpenAiAudioTranscriptionProperties.java | 57 ++ .../openai/AzureOpenAiAutoConfiguration.java | 193 +++++ .../openai/AzureOpenAiChatProperties.java | 59 ++ .../AzureOpenAiConnectionProperties.java | 81 +++ .../AzureOpenAiEmbeddingProperties.java | 68 ++ .../AzureOpenAiImageOptionsProperties.java | 58 ++ ...itional-spring-configuration-metadata.json | 23 + ...ot.autoconfigure.AutoConfiguration.imports | 16 + .../azure/AzureOpenAiAutoConfigurationIT.java | 252 +++++++ ...eOpenAiAutoConfigurationPropertyTests.java | 100 +++ ...OpenAiDirectOpenAiAutoConfigurationIT.java | 123 ++++ .../azure/tool/DeploymentNameUtil.java | 37 + .../tool/FunctionCallWithFunctionBeanIT.java | 127 ++++ .../FunctionCallWithFunctionWrapperIT.java | 91 +++ .../FunctionCallWithPromptFunctionIT.java | 79 ++ .../azure/tool/MockWeatherService.java | 96 +++ .../pom.xml | 100 +++ .../BedrockAwsConnectionConfiguration.java | 93 +++ .../BedrockAwsConnectionProperties.java | 100 +++ ...drockCohereEmbeddingAutoConfiguration.java | 69 ++ .../BedrockCohereEmbeddingProperties.java | 78 ++ ...ockConverseProxyChatAutoConfiguration.java | 97 +++ .../BedrockConverseProxyChatProperties.java | 64 ++ ...edrockTitanEmbeddingAutoConfiguration.java | 69 ++ .../BedrockTitanEmbeddingProperties.java | 78 ++ ...ot.autoconfigure.AutoConfiguration.imports | 18 + .../BedrockAwsConnectionConfigurationIT.java | 132 ++++ .../bedrock/BedrockTestUtils.java | 54 ++ .../bedrock/RequiresAwsCredentials.java | 35 + ...ockCohereEmbeddingAutoConfigurationIT.java | 141 ++++ ...kConverseProxyChatAutoConfigurationIT.java | 81 +++ ...drockConverseProxyChatPropertiesTests.java | 84 +++ .../tool/FunctionCallWithFunctionBeanIT.java | 130 ++++ .../FunctionCallWithPromptFunctionIT.java | 75 ++ .../converse/tool/MockWeatherService.java | 95 +++ ...rockTitanEmbeddingAutoConfigurationIT.java | 140 ++++ .../pom.xml | 86 +++ .../HuggingfaceChatAutoConfiguration.java | 40 + .../HuggingfaceChatProperties.java | 73 ++ ...ot.autoconfigure.AutoConfiguration.imports | 16 + .../HuggingfaceChatAutoConfigurationIT.java | 84 +++ .../pom.xml | 93 +++ .../minimax/MiniMaxAutoConfiguration.java | 123 ++++ .../minimax/MiniMaxChatProperties.java | 65 ++ .../minimax/MiniMaxConnectionProperties.java | 32 + .../minimax/MiniMaxEmbeddingProperties.java | 71 ++ .../minimax/MiniMaxParentProperties.java | 44 ++ ...ot.autoconfigure.AutoConfiguration.imports | 16 + .../minimax/FunctionCallbackInPromptIT.java | 117 +++ ...nctionCallbackWithPlainFunctionBeanIT.java | 177 +++++ .../minimax/MiniMaxAutoConfigurationIT.java | 97 +++ .../minimax/MiniMaxFunctionCallbackIT.java | 122 ++++ .../minimax/MiniMaxPropertiesTests.java | 331 +++++++++ .../minimax/MockWeatherService.java | 97 +++ .../pom.xml | 107 +++ .../mistralai/MistralAiAutoConfiguration.java | 134 ++++ .../mistralai/MistralAiChatProperties.java | 79 ++ .../mistralai/MistralAiCommonProperties.java | 39 + .../MistralAiEmbeddingProperties.java | 81 +++ .../mistralai/MistralAiParentProperties.java | 47 ++ ...itional-spring-configuration-metadata.json | 11 + ...ot.autoconfigure.AutoConfiguration.imports | 16 + .../MistralAiAutoConfigurationIT.java | 95 +++ .../mistralai/MistralAiPropertiesTests.java | 148 ++++ .../mistralai/tool/PaymentStatusBeanIT.java | 115 +++ .../tool/PaymentStatusBeanOpenAiIT.java | 122 ++++ .../mistralai/tool/PaymentStatusPromptIT.java | 96 +++ .../tool/WeatherServicePromptIT.java | 148 ++++ .../pom.xml | 93 +++ .../moonshot/MoonshotAutoConfiguration.java | 97 +++ .../moonshot/MoonshotChatProperties.java | 66 ++ .../moonshot/MoonshotCommonProperties.java | 37 + .../moonshot/MoonshotParentProperties.java | 46 ++ ...ot.autoconfigure.AutoConfiguration.imports | 16 + .../moonshot/MoonshotAutoConfigurationIT.java | 77 ++ .../moonshot/MoonshotPropertiesTests.java | 166 +++++ .../tool/FunctionCallbackInPromptIT.java | 119 +++ ...nctionCallbackWithPlainFunctionBeanIT.java | 177 +++++ .../moonshot/tool/MockWeatherService.java | 95 +++ .../tool/MoonshotFunctionCallbackIT.java | 126 ++++ .../pom.xml | 93 +++ .../genai/OCICohereChatModelProperties.java | 61 ++ .../oci/genai/OCIConnectionProperties.java | 153 ++++ .../genai/OCIEmbeddingModelProperties.java | 93 +++ .../oci/genai/OCIGenAiAutoConfiguration.java | 113 +++ .../autoconfigure/oci/genai/ServingMode.java | 38 + ...ot.autoconfigure.AutoConfiguration.imports | 16 + .../genai/OCIGenAIAutoConfigurationTest.java | 93 +++ .../genai/OCIGenAiAutoConfigurationIT.java | 90 +++ .../pom.xml | 98 +++ .../ollama/OllamaAutoConfiguration.java | 151 ++++ .../ollama/OllamaChatProperties.java | 68 ++ .../ollama/OllamaConnectionDetails.java | 30 + .../ollama/OllamaConnectionProperties.java | 45 ++ .../ollama/OllamaEmbeddingProperties.java | 68 ++ .../OllamaInitializationProperties.java | 124 ++++ ...ot.autoconfigure.AutoConfiguration.imports | 16 + .../ai/autoconfigure/ollama/BaseOllamaIT.java | 115 +++ .../ollama/OllamaChatAutoConfigurationIT.java | 133 ++++ .../OllamaChatAutoConfigurationTests.java | 60 ++ .../OllamaEmbeddingAutoConfigurationIT.java | 103 +++ ...OllamaEmbeddingAutoConfigurationTests.java | 57 ++ .../ai/autoconfigure/ollama/OllamaImage.java | 27 + .../tool/FunctionCallbackInPromptIT.java | 125 ++++ .../ollama/tool/MockWeatherService.java | 97 +++ .../ollama/tool/OllamaFunctionCallbackIT.java | 147 ++++ .../ollama/tool/OllamaFunctionToolBeanIT.java | 195 +++++ .../pom.xml | 101 +++ .../openai/OpenAiAudioSpeechProperties.java | 75 ++ .../OpenAiAudioTranscriptionProperties.java | 63 ++ .../openai/OpenAiAutoConfiguration.java | 310 ++++++++ .../openai/OpenAiChatProperties.java | 71 ++ .../openai/OpenAiConnectionProperties.java | 32 + .../openai/OpenAiEmbeddingProperties.java | 77 ++ .../openai/OpenAiImageProperties.java | 64 ++ .../openai/OpenAiModerationProperties.java | 48 ++ .../openai/OpenAiParentProperties.java | 67 ++ ...itional-spring-configuration-metadata.json | 22 + ...ot.autoconfigure.AutoConfiguration.imports | 16 + .../openai/ChatClientAutoConfigurationIT.java | 121 +++ .../openai/OpenAiAutoConfigurationIT.java | 205 ++++++ .../openai/OpenAiPropertiesTests.java | 687 ++++++++++++++++++ .../OpenAiResponseFormatPropertiesTests.java | 357 +++++++++ .../tool/FunctionCallbackInPrompt2IT.java | 156 ++++ .../tool/FunctionCallbackInPromptIT.java | 117 +++ ...nctionCallbackWithPlainFunctionBeanIT.java | 431 +++++++++++ .../openai/tool/MockWeatherService.java | 97 +++ .../tool/OpenAiFunctionCallback2IT.java | 107 +++ .../openai/tool/OpenAiFunctionCallbackIT.java | 118 +++ .../pom.xml | 111 +++ .../PostgresMlAutoConfiguration.java | 51 ++ .../PostgresMlEmbeddingProperties.java | 87 +++ ...ot.autoconfigure.AutoConfiguration.imports | 16 + .../PostgresMlAutoConfigurationIT.java | 113 +++ .../PostgresMlEmbeddingPropertiesTests.java | 64 ++ .../pom.xml | 86 +++ .../qianfan/QianFanAutoConfiguration.java | 165 +++++ .../qianfan/QianFanChatProperties.java | 65 ++ .../qianfan/QianFanConnectionProperties.java | 33 + .../qianfan/QianFanEmbeddingProperties.java | 71 ++ .../qianfan/QianFanImageProperties.java | 63 ++ .../qianfan/QianFanParentProperties.java | 54 ++ ...ot.autoconfigure.AutoConfiguration.imports | 16 + .../qianfan/QianFanAutoConfigurationIT.java | 114 +++ .../qianfan/QianFanPropertiesTests.java | 440 +++++++++++ .../pom.xml | 86 +++ .../StabilityAiConnectionProperties.java | 33 + .../StabilityAiImageAutoConfiguration.java | 72 ++ .../StabilityAiImageProperties.java | 63 ++ .../StabilityAiParentProperties.java | 47 ++ ...ot.autoconfigure.AutoConfiguration.imports | 16 + .../StabilityAiAutoConfigurationIT.java | 63 ++ .../StabilityAiImagePropertiesTests.java | 110 +++ .../pom.xml | 86 +++ ...ormersEmbeddingModelAutoConfiguration.java | 71 ++ .../TransformersEmbeddingModelProperties.java | 219 ++++++ ...ot.autoconfigure.AutoConfiguration.imports | 16 + ...mersEmbeddingModelAutoConfigurationIT.java | 112 +++ .../pom.xml | 114 +++ .../VertexAiEmbeddingAutoConfiguration.java | 102 +++ ...VertexAiEmbeddingConnectionProperties.java | 85 +++ ...VertexAiMultimodalEmbeddingProperties.java | 58 ++ .../VertexAiTextEmbeddingProperties.java | 59 ++ .../VertexAiGeminiAutoConfiguration.java | 125 ++++ .../gemini/VertexAiGeminiChatProperties.java | 53 ++ .../VertexAiGeminiConnectionProperties.java | 122 ++++ ...ot.autoconfigure.AutoConfiguration.imports | 17 + ...TextEmbeddingModelAutoConfigurationIT.java | 144 ++++ .../VertexAiGeminiAutoConfigurationIT.java | 73 ++ .../tool/FunctionCallWithFunctionBeanIT.java | 147 ++++ .../FunctionCallWithFunctionWrapperIT.java | 90 +++ .../FunctionCallWithPromptFunctionIT.java | 96 +++ .../gemini/tool/MockWeatherService.java | 96 +++ .../pom.xml | 79 ++ .../watsonxai/WatsonxAiAutoConfiguration.java | 74 ++ .../watsonxai/WatsonxAiChatProperties.java | 74 ++ .../WatsonxAiConnectionProperties.java | 93 +++ .../WatsonxAiEmbeddingProperties.java | 67 ++ ...ot.autoconfigure.AutoConfiguration.imports | 16 + .../WatsonxAiAutoConfigurationTests.java | 56 ++ .../pom.xml | 86 +++ .../zhipuai/ZhiPuAiAutoConfiguration.java | 147 ++++ .../zhipuai/ZhiPuAiChatProperties.java | 65 ++ .../zhipuai/ZhiPuAiConnectionProperties.java | 32 + .../zhipuai/ZhiPuAiEmbeddingProperties.java | 71 ++ .../zhipuai/ZhiPuAiImageProperties.java | 60 ++ .../zhipuai/ZhiPuAiParentProperties.java | 44 ++ ...ot.autoconfigure.AutoConfiguration.imports | 16 + .../zhipuai/ZhiPuAiAutoConfigurationIT.java | 111 +++ .../zhipuai/ZhiPuAiPropertiesTests.java | 432 +++++++++++ .../tool/FunctionCallbackInPromptIT.java | 120 +++ ...nctionCallbackWithPlainFunctionBeanIT.java | 176 +++++ .../zhipuai/tool/MockWeatherService.java | 97 +++ .../tool/ZhipuAiFunctionCallbackIT.java | 124 ++++ .../pom.xml | 72 ++ .../ChatObservationAutoConfiguration.java | 156 ++++ .../ChatObservationProperties.java | 71 ++ .../chat/observation/package-info.java | 25 + ...ot.autoconfigure.AutoConfiguration.imports | 16 + ...ChatObservationAutoConfigurationTests.java | 108 +++ .../pom.xml | 66 ++ ...EmbeddingObservationAutoConfiguration.java | 49 ++ .../embedding/observation/package-info.java | 25 + ...ot.autoconfigure.AutoConfiguration.imports | 16 + ...dingObservationAutoConfigurationTests.java | 50 ++ .../pom.xml | 66 ++ .../ImageObservationAutoConfiguration.java | 55 ++ .../ImageObservationProperties.java | 45 ++ .../image/observation/package-info.java | 25 + ...ot.autoconfigure.AutoConfiguration.imports | 16 + ...mageObservationAutoConfigurationTests.java | 49 ++ pom.xml | 35 +- spring-ai-bom/pom.xml | 151 ++++ spring-ai-spring-boot-docker-compose/pom.xml | 2 + .../spring-ai-starter-anthropic/pom.xml | 4 +- .../spring-ai-starter-azure-openai/pom.xml | 4 +- .../spring-ai-starter-bedrock-ai/pom.xml | 4 +- .../pom.xml | 4 +- .../spring-ai-starter-huggingface/pom.xml | 4 +- .../pom.xml | 4 +- .../spring-ai-starter-mcp-client/pom.xml | 2 +- .../pom.xml | 4 +- .../pom.xml | 4 +- .../spring-ai-starter-mcp-server/pom.xml | 2 +- .../spring-ai-starter-minimax/pom.xml | 4 +- .../spring-ai-starter-mistral-ai/pom.xml | 4 +- .../spring-ai-starter-moonshot/pom.xml | 4 +- .../spring-ai-starter-oci-genai/pom.xml | 2 +- .../spring-ai-starter-ollama/pom.xml | 4 +- .../spring-ai-starter-openai/pom.xml | 4 +- .../pom.xml | 4 +- .../spring-ai-starter-qianfan/pom.xml | 2 +- .../spring-ai-starter-stability-ai/pom.xml | 4 +- .../spring-ai-starter-transformers/pom.xml | 4 +- .../pom.xml | 4 +- .../pom.xml | 4 +- .../spring-ai-starter-watsonx-ai/pom.xml | 6 +- .../spring-ai-starter-zhipuai/pom.xml | 4 +- spring-ai-spring-boot-testcontainers/pom.xml | 2 + 295 files changed, 22766 insertions(+), 50 deletions(-) create mode 100644 auto-configurations/chat/spring-ai-chat-client-spring-boot-autoconfigure/pom.xml create mode 100644 auto-configurations/chat/spring-ai-chat-client-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/client/ChatClientAutoConfiguration.java create mode 100644 auto-configurations/chat/spring-ai-chat-client-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/client/ChatClientBuilderConfigurer.java create mode 100644 auto-configurations/chat/spring-ai-chat-client-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/client/ChatClientBuilderProperties.java create mode 100644 auto-configurations/chat/spring-ai-chat-client-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 auto-configurations/chat/spring-ai-chat-client-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/client/ChatClientObservationAutoConfigurationTests.java create mode 100644 auto-configurations/chat/spring-ai-chat-memory-spring-boot-autoconfigure/pom.xml create mode 100644 auto-configurations/chat/spring-ai-chat-memory-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/memory/CommonChatMemoryProperties.java create mode 100644 auto-configurations/chat/spring-ai-chat-memory-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/memory/cassandra/CassandraChatMemoryAutoConfiguration.java create mode 100644 auto-configurations/chat/spring-ai-chat-memory-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/memory/cassandra/CassandraChatMemoryProperties.java create mode 100644 auto-configurations/chat/spring-ai-chat-memory-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 auto-configurations/chat/spring-ai-chat-memory-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/cassandra/CassandraChatMemoryAutoConfigurationIT.java create mode 100644 auto-configurations/chat/spring-ai-chat-memory-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/cassandra/CassandraChatMemoryPropertiesTest.java create mode 100644 auto-configurations/chat/spring-ai-chat-model-spring-boot-autoconfigure/pom.xml create mode 100644 auto-configurations/chat/spring-ai-chat-model-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/model/ToolCallingAutoConfiguration.java create mode 100644 auto-configurations/chat/spring-ai-chat-model-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 auto-configurations/chat/spring-ai-chat-model-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/model/ToolCallingAutoConfigurationTests.java create mode 100644 auto-configurations/common/spring-ai-retry-spring-boot-autoconfigure/pom.xml create mode 100644 auto-configurations/common/spring-ai-retry-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/retry/SpringAiRetryAutoConfiguration.java create mode 100644 auto-configurations/common/spring-ai-retry-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/retry/SpringAiRetryProperties.java create mode 100644 auto-configurations/common/spring-ai-retry-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 auto-configurations/common/spring-ai-retry-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/retry/SpringAiRetryAutoConfigurationIT.java create mode 100644 auto-configurations/common/spring-ai-retry-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/retry/SpringAiRetryPropertiesTests.java rename auto-configurations/{ => mcp}/spring-ai-mcp-client/pom.xml (98%) rename auto-configurations/{ => mcp}/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/McpClientAutoConfiguration.java (100%) rename auto-configurations/{ => mcp}/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/NamedClientMcpTransport.java (100%) rename auto-configurations/{ => mcp}/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/SseHttpClientTransportAutoConfiguration.java (100%) rename auto-configurations/{ => mcp}/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/SseWebFluxTransportAutoConfiguration.java (100%) rename auto-configurations/{ => mcp}/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/StdioTransportAutoConfiguration.java (100%) rename auto-configurations/{ => mcp}/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/configurer/McpAsyncClientConfigurer.java (100%) rename auto-configurations/{ => mcp}/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/configurer/McpSyncClientConfigurer.java (100%) rename auto-configurations/{ => mcp}/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/properties/McpClientCommonProperties.java (100%) rename auto-configurations/{ => mcp}/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/properties/McpSseClientProperties.java (100%) rename auto-configurations/{ => mcp}/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/properties/McpStdioClientProperties.java (100%) rename auto-configurations/{ => mcp}/spring-ai-mcp-client/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports (100%) rename auto-configurations/{ => mcp}/spring-ai-mcp-client/src/test/java/org/springframework/ai/autoconfigure/mcp/client/McpClientAutoConfigurationIT.java (100%) rename auto-configurations/{ => mcp}/spring-ai-mcp-client/src/test/resources/application-test.properties (100%) rename auto-configurations/{ => mcp}/spring-ai-mcp-server/pom.xml (97%) rename auto-configurations/{ => mcp}/spring-ai-mcp-server/src/main/java/org/springframework/ai/autoconfigure/mcp/server/McpServerAutoConfiguration.java (100%) rename auto-configurations/{ => mcp}/spring-ai-mcp-server/src/main/java/org/springframework/ai/autoconfigure/mcp/server/McpServerProperties.java (100%) rename auto-configurations/{ => mcp}/spring-ai-mcp-server/src/main/java/org/springframework/ai/autoconfigure/mcp/server/McpWebFluxServerAutoConfiguration.java (100%) rename auto-configurations/{ => mcp}/spring-ai-mcp-server/src/main/java/org/springframework/ai/autoconfigure/mcp/server/McpWebMvcServerAutoConfiguration.java (100%) rename auto-configurations/{ => mcp}/spring-ai-mcp-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports (100%) rename auto-configurations/{ => mcp}/spring-ai-mcp-server/src/test/java/org/springframework/ai/autoconfigure/mcp/server/McpServerAutoConfigurationIT.java (100%) create mode 100644 auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/pom.xml create mode 100644 auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/anthropic/AnthropicAutoConfiguration.java create mode 100644 auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/anthropic/AnthropicChatProperties.java create mode 100644 auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/anthropic/AnthropicConnectionProperties.java create mode 100644 auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/anthropic/AnthropicAutoConfigurationIT.java create mode 100644 auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/anthropic/AnthropicPropertiesTests.java create mode 100644 auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/anthropic/tool/FunctionCallWithFunctionBeanIT.java create mode 100644 auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/anthropic/tool/FunctionCallWithPromptFunctionIT.java create mode 100644 auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/anthropic/tool/MockWeatherService.java create mode 100644 auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/pom.xml create mode 100644 auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/azure/openai/AzureOpenAIClientBuilderCustomizer.java create mode 100644 auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/azure/openai/AzureOpenAiAudioTranscriptionProperties.java create mode 100644 auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/azure/openai/AzureOpenAiAutoConfiguration.java create mode 100644 auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/azure/openai/AzureOpenAiChatProperties.java create mode 100644 auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/azure/openai/AzureOpenAiConnectionProperties.java create mode 100644 auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/azure/openai/AzureOpenAiEmbeddingProperties.java create mode 100644 auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/azure/openai/AzureOpenAiImageOptionsProperties.java create mode 100644 auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json create mode 100644 auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/AzureOpenAiAutoConfigurationIT.java create mode 100644 auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/AzureOpenAiAutoConfigurationPropertyTests.java create mode 100644 auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/AzureOpenAiDirectOpenAiAutoConfigurationIT.java create mode 100644 auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/tool/DeploymentNameUtil.java create mode 100644 auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/tool/FunctionCallWithFunctionBeanIT.java create mode 100644 auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/tool/FunctionCallWithFunctionWrapperIT.java create mode 100644 auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/tool/FunctionCallWithPromptFunctionIT.java create mode 100644 auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/tool/MockWeatherService.java create mode 100644 auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/pom.xml create mode 100644 auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/BedrockAwsConnectionConfiguration.java create mode 100644 auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/BedrockAwsConnectionProperties.java create mode 100644 auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/cohere/BedrockCohereEmbeddingAutoConfiguration.java create mode 100644 auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/cohere/BedrockCohereEmbeddingProperties.java create mode 100644 auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/converse/BedrockConverseProxyChatAutoConfiguration.java create mode 100644 auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/converse/BedrockConverseProxyChatProperties.java create mode 100644 auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/titan/BedrockTitanEmbeddingAutoConfiguration.java create mode 100644 auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/titan/BedrockTitanEmbeddingProperties.java create mode 100644 auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/BedrockAwsConnectionConfigurationIT.java create mode 100644 auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/BedrockTestUtils.java create mode 100644 auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/RequiresAwsCredentials.java create mode 100644 auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/cohere/BedrockCohereEmbeddingAutoConfigurationIT.java create mode 100644 auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/converse/BedrockConverseProxyChatAutoConfigurationIT.java create mode 100644 auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/converse/BedrockConverseProxyChatPropertiesTests.java create mode 100644 auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/converse/tool/FunctionCallWithFunctionBeanIT.java create mode 100644 auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/converse/tool/FunctionCallWithPromptFunctionIT.java create mode 100644 auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/converse/tool/MockWeatherService.java create mode 100644 auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/titan/BedrockTitanEmbeddingAutoConfigurationIT.java create mode 100644 auto-configurations/models/spring-ai-huggingface-spring-boot-autoconfigure/pom.xml create mode 100644 auto-configurations/models/spring-ai-huggingface-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/huggingface/HuggingfaceChatAutoConfiguration.java create mode 100644 auto-configurations/models/spring-ai-huggingface-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/huggingface/HuggingfaceChatProperties.java create mode 100644 auto-configurations/models/spring-ai-huggingface-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 auto-configurations/models/spring-ai-huggingface-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/huggingface/HuggingfaceChatAutoConfigurationIT.java create mode 100644 auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/pom.xml create mode 100644 auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxAutoConfiguration.java create mode 100644 auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxChatProperties.java create mode 100644 auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxConnectionProperties.java create mode 100644 auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxEmbeddingProperties.java create mode 100644 auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxParentProperties.java create mode 100644 auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/FunctionCallbackInPromptIT.java create mode 100644 auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/FunctionCallbackWithPlainFunctionBeanIT.java create mode 100644 auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MiniMaxAutoConfigurationIT.java create mode 100644 auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MiniMaxFunctionCallbackIT.java create mode 100644 auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MiniMaxPropertiesTests.java create mode 100644 auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MockWeatherService.java create mode 100644 auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/pom.xml create mode 100644 auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiAutoConfiguration.java create mode 100644 auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiChatProperties.java create mode 100644 auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiCommonProperties.java create mode 100644 auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiEmbeddingProperties.java create mode 100644 auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiParentProperties.java create mode 100644 auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json create mode 100644 auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/MistralAiAutoConfigurationIT.java create mode 100644 auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/MistralAiPropertiesTests.java create mode 100644 auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/tool/PaymentStatusBeanIT.java create mode 100644 auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/tool/PaymentStatusBeanOpenAiIT.java create mode 100644 auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/tool/PaymentStatusPromptIT.java create mode 100644 auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/tool/WeatherServicePromptIT.java create mode 100644 auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/pom.xml create mode 100644 auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/moonshot/MoonshotAutoConfiguration.java create mode 100644 auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/moonshot/MoonshotChatProperties.java create mode 100644 auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/moonshot/MoonshotCommonProperties.java create mode 100644 auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/moonshot/MoonshotParentProperties.java create mode 100644 auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/moonshot/MoonshotAutoConfigurationIT.java create mode 100644 auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/moonshot/MoonshotPropertiesTests.java create mode 100644 auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/moonshot/tool/FunctionCallbackInPromptIT.java create mode 100644 auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/moonshot/tool/FunctionCallbackWithPlainFunctionBeanIT.java create mode 100644 auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/moonshot/tool/MockWeatherService.java create mode 100644 auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/moonshot/tool/MoonshotFunctionCallbackIT.java create mode 100644 auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure/pom.xml create mode 100644 auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/oci/genai/OCICohereChatModelProperties.java create mode 100644 auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/oci/genai/OCIConnectionProperties.java create mode 100644 auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/oci/genai/OCIEmbeddingModelProperties.java create mode 100644 auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/oci/genai/OCIGenAiAutoConfiguration.java create mode 100644 auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/oci/genai/ServingMode.java create mode 100644 auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/oci/genai/OCIGenAIAutoConfigurationTest.java create mode 100644 auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/oci/genai/OCIGenAiAutoConfigurationIT.java create mode 100644 auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/pom.xml create mode 100644 auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/ollama/OllamaAutoConfiguration.java create mode 100644 auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/ollama/OllamaChatProperties.java create mode 100644 auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/ollama/OllamaConnectionDetails.java create mode 100644 auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/ollama/OllamaConnectionProperties.java create mode 100644 auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/ollama/OllamaEmbeddingProperties.java create mode 100644 auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/ollama/OllamaInitializationProperties.java create mode 100644 auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/BaseOllamaIT.java create mode 100644 auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/OllamaChatAutoConfigurationIT.java create mode 100644 auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/OllamaChatAutoConfigurationTests.java create mode 100644 auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/OllamaEmbeddingAutoConfigurationIT.java create mode 100644 auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/OllamaEmbeddingAutoConfigurationTests.java create mode 100644 auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/OllamaImage.java create mode 100644 auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/tool/FunctionCallbackInPromptIT.java create mode 100644 auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/tool/MockWeatherService.java create mode 100644 auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/tool/OllamaFunctionCallbackIT.java create mode 100644 auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/tool/OllamaFunctionToolBeanIT.java create mode 100644 auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/pom.xml create mode 100644 auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAudioSpeechProperties.java create mode 100644 auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAudioTranscriptionProperties.java create mode 100644 auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfiguration.java create mode 100644 auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiChatProperties.java create mode 100644 auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiConnectionProperties.java create mode 100644 auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiEmbeddingProperties.java create mode 100644 auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiImageProperties.java create mode 100644 auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiModerationProperties.java create mode 100644 auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiParentProperties.java create mode 100644 auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json create mode 100644 auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/ChatClientAutoConfigurationIT.java create mode 100644 auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfigurationIT.java create mode 100644 auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/OpenAiPropertiesTests.java create mode 100644 auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/OpenAiResponseFormatPropertiesTests.java create mode 100644 auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/FunctionCallbackInPrompt2IT.java create mode 100644 auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/FunctionCallbackInPromptIT.java create mode 100644 auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/FunctionCallbackWithPlainFunctionBeanIT.java create mode 100644 auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/MockWeatherService.java create mode 100644 auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/OpenAiFunctionCallback2IT.java create mode 100644 auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/OpenAiFunctionCallbackIT.java create mode 100644 auto-configurations/models/spring-ai-postgresml-spring-boot-autoconfigure/pom.xml create mode 100644 auto-configurations/models/spring-ai-postgresml-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/postgresml/PostgresMlAutoConfiguration.java create mode 100644 auto-configurations/models/spring-ai-postgresml-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/postgresml/PostgresMlEmbeddingProperties.java create mode 100644 auto-configurations/models/spring-ai-postgresml-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 auto-configurations/models/spring-ai-postgresml-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/postgresml/PostgresMlAutoConfigurationIT.java create mode 100644 auto-configurations/models/spring-ai-postgresml-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/postgresml/PostgresMlEmbeddingPropertiesTests.java create mode 100644 auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/pom.xml create mode 100644 auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/qianfan/QianFanAutoConfiguration.java create mode 100644 auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/qianfan/QianFanChatProperties.java create mode 100644 auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/qianfan/QianFanConnectionProperties.java create mode 100644 auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/qianfan/QianFanEmbeddingProperties.java create mode 100644 auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/qianfan/QianFanImageProperties.java create mode 100644 auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/qianfan/QianFanParentProperties.java create mode 100644 auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/qianfan/QianFanAutoConfigurationIT.java create mode 100644 auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/qianfan/QianFanPropertiesTests.java create mode 100644 auto-configurations/models/spring-ai-stability-ai-spring-boot-autoconfigure/pom.xml create mode 100644 auto-configurations/models/spring-ai-stability-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/stabilityai/StabilityAiConnectionProperties.java create mode 100644 auto-configurations/models/spring-ai-stability-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/stabilityai/StabilityAiImageAutoConfiguration.java create mode 100644 auto-configurations/models/spring-ai-stability-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/stabilityai/StabilityAiImageProperties.java create mode 100644 auto-configurations/models/spring-ai-stability-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/stabilityai/StabilityAiParentProperties.java create mode 100644 auto-configurations/models/spring-ai-stability-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 auto-configurations/models/spring-ai-stability-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/stabilityai/StabilityAiAutoConfigurationIT.java create mode 100644 auto-configurations/models/spring-ai-stability-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/stabilityai/StabilityAiImagePropertiesTests.java create mode 100644 auto-configurations/models/spring-ai-transformers-spring-boot-autoconfigure/pom.xml create mode 100644 auto-configurations/models/spring-ai-transformers-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/transformers/TransformersEmbeddingModelAutoConfiguration.java create mode 100644 auto-configurations/models/spring-ai-transformers-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/transformers/TransformersEmbeddingModelProperties.java create mode 100644 auto-configurations/models/spring-ai-transformers-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 auto-configurations/models/spring-ai-transformers-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/transformers/TransformersEmbeddingModelAutoConfigurationIT.java create mode 100644 auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/pom.xml create mode 100644 auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vertexai/embedding/VertexAiEmbeddingAutoConfiguration.java create mode 100644 auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vertexai/embedding/VertexAiEmbeddingConnectionProperties.java create mode 100644 auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vertexai/embedding/VertexAiMultimodalEmbeddingProperties.java create mode 100644 auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vertexai/embedding/VertexAiTextEmbeddingProperties.java create mode 100644 auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vertexai/gemini/VertexAiGeminiAutoConfiguration.java create mode 100644 auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vertexai/gemini/VertexAiGeminiChatProperties.java create mode 100644 auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vertexai/gemini/VertexAiGeminiConnectionProperties.java create mode 100644 auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vertexai/embedding/VertexAiTextEmbeddingModelAutoConfigurationIT.java create mode 100644 auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vertexai/gemini/VertexAiGeminiAutoConfigurationIT.java create mode 100644 auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vertexai/gemini/tool/FunctionCallWithFunctionBeanIT.java create mode 100644 auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vertexai/gemini/tool/FunctionCallWithFunctionWrapperIT.java create mode 100644 auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vertexai/gemini/tool/FunctionCallWithPromptFunctionIT.java create mode 100644 auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vertexai/gemini/tool/MockWeatherService.java create mode 100644 auto-configurations/models/spring-ai-watsonx-ai-spring-boot-autoconfigure/pom.xml create mode 100644 auto-configurations/models/spring-ai-watsonx-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAiAutoConfiguration.java create mode 100644 auto-configurations/models/spring-ai-watsonx-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAiChatProperties.java create mode 100644 auto-configurations/models/spring-ai-watsonx-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAiConnectionProperties.java create mode 100644 auto-configurations/models/spring-ai-watsonx-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAiEmbeddingProperties.java create mode 100644 auto-configurations/models/spring-ai-watsonx-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 auto-configurations/models/spring-ai-watsonx-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAiAutoConfigurationTests.java create mode 100644 auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/pom.xml create mode 100644 auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiAutoConfiguration.java create mode 100644 auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiChatProperties.java create mode 100644 auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiConnectionProperties.java create mode 100644 auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiEmbeddingProperties.java create mode 100644 auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiImageProperties.java create mode 100644 auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiParentProperties.java create mode 100644 auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiAutoConfigurationIT.java create mode 100644 auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiPropertiesTests.java create mode 100644 auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/tool/FunctionCallbackInPromptIT.java create mode 100644 auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/tool/FunctionCallbackWithPlainFunctionBeanIT.java create mode 100644 auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/tool/MockWeatherService.java create mode 100644 auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/tool/ZhipuAiFunctionCallbackIT.java create mode 100644 auto-configurations/observation/chat/spring-ai-chat-observation-spring-boot-autoconfigure/pom.xml create mode 100644 auto-configurations/observation/chat/spring-ai-chat-observation-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationAutoConfiguration.java create mode 100644 auto-configurations/observation/chat/spring-ai-chat-observation-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationProperties.java create mode 100644 auto-configurations/observation/chat/spring-ai-chat-observation-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/observation/package-info.java create mode 100644 auto-configurations/observation/chat/spring-ai-chat-observation-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 auto-configurations/observation/chat/spring-ai-chat-observation-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationAutoConfigurationTests.java create mode 100644 auto-configurations/observation/embedding/spring-ai-embedding-observation-spring-boot-autoconfigure/pom.xml create mode 100644 auto-configurations/observation/embedding/spring-ai-embedding-observation-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/embedding/observation/EmbeddingObservationAutoConfiguration.java create mode 100644 auto-configurations/observation/embedding/spring-ai-embedding-observation-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/embedding/observation/package-info.java create mode 100644 auto-configurations/observation/embedding/spring-ai-embedding-observation-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 auto-configurations/observation/embedding/spring-ai-embedding-observation-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/embedding/observation/EmbeddingObservationAutoConfigurationTests.java create mode 100644 auto-configurations/observation/image/spring-ai-image-observation-spring-boot-autoconfigure/pom.xml create mode 100644 auto-configurations/observation/image/spring-ai-image-observation-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/image/observation/ImageObservationAutoConfiguration.java create mode 100644 auto-configurations/observation/image/spring-ai-image-observation-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/image/observation/ImageObservationProperties.java create mode 100644 auto-configurations/observation/image/spring-ai-image-observation-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/image/observation/package-info.java create mode 100644 auto-configurations/observation/image/spring-ai-image-observation-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 auto-configurations/observation/image/spring-ai-image-observation-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/image/observation/ImageObservationAutoConfigurationTests.java diff --git a/auto-configurations/chat/spring-ai-chat-client-spring-boot-autoconfigure/pom.xml b/auto-configurations/chat/spring-ai-chat-client-spring-boot-autoconfigure/pom.xml new file mode 100644 index 00000000000..881402d0ca1 --- /dev/null +++ b/auto-configurations/chat/spring-ai-chat-client-spring-boot-autoconfigure/pom.xml @@ -0,0 +1,66 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../../pom.xml + + spring-ai-chat-client-spring-boot-autoconfigure + jar + Spring AI Chat Client Auto Configuration + Spring AI Chat Client 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-core + ${parent.version} + + + + + org.springframework.boot + spring-boot-starter + + + + org.springframework.boot + spring-boot-configuration-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/chat/spring-ai-chat-client-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/client/ChatClientAutoConfiguration.java b/auto-configurations/chat/spring-ai-chat-client-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/client/ChatClientAutoConfiguration.java new file mode 100644 index 00000000000..f815dde2e37 --- /dev/null +++ b/auto-configurations/chat/spring-ai-chat-client-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/client/ChatClientAutoConfiguration.java @@ -0,0 +1,92 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.chat.client; + +import io.micrometer.observation.ObservationRegistry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.ChatClientCustomizer; +import org.springframework.ai.chat.client.observation.ChatClientInputContentObservationFilter; +import org.springframework.ai.chat.client.observation.ChatClientObservationConvention; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +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.context.annotation.Bean; +import org.springframework.context.annotation.Scope; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for {@link ChatClient}. + *

+ * This will produce a {@link ChatClient.Builder ChatClient.Builder} bean with the + * {@code prototype} scope, meaning each injection point will receive a newly cloned + * instance of the builder. + * + * @author Christian Tzolov + * @author Mark Pollack + * @author Josh Long + * @author Arjen Poutsma + * @author Thomas Vitale + * @since 1.0.0 + */ +@AutoConfiguration +@ConditionalOnClass(ChatClient.class) +@EnableConfigurationProperties(ChatClientBuilderProperties.class) +@ConditionalOnProperty(prefix = ChatClientBuilderProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) +public class ChatClientAutoConfiguration { + + private static final Logger logger = LoggerFactory.getLogger(ChatClientAutoConfiguration.class); + + @Bean + @ConditionalOnMissingBean + ChatClientBuilderConfigurer chatClientBuilderConfigurer(ObjectProvider customizerProvider) { + ChatClientBuilderConfigurer configurer = new ChatClientBuilderConfigurer(); + configurer.setChatClientCustomizers(customizerProvider.orderedStream().toList()); + return configurer; + } + + @Bean + @Scope("prototype") + @ConditionalOnMissingBean + ChatClient.Builder chatClientBuilder(ChatClientBuilderConfigurer chatClientBuilderConfigurer, ChatModel chatModel, + ObjectProvider observationRegistry, + ObjectProvider observationConvention) { + + ChatClient.Builder builder = ChatClient.builder(chatModel, + observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP), + observationConvention.getIfUnique(() -> null)); + return chatClientBuilderConfigurer.configure(builder); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = ChatClientBuilderProperties.CONFIG_PREFIX + ".observations", name = "include-input", + havingValue = "true") + ChatClientInputContentObservationFilter chatClientInputContentObservationFilter() { + logger.warn( + "You have enabled the inclusion of the input content in the observations, with the risk of exposing sensitive or private information. Please, be careful!"); + return new ChatClientInputContentObservationFilter(); + } + +} diff --git a/auto-configurations/chat/spring-ai-chat-client-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/client/ChatClientBuilderConfigurer.java b/auto-configurations/chat/spring-ai-chat-client-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/client/ChatClientBuilderConfigurer.java new file mode 100644 index 00000000000..a59653855f7 --- /dev/null +++ b/auto-configurations/chat/spring-ai-chat-client-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/client/ChatClientBuilderConfigurer.java @@ -0,0 +1,60 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.chat.client; + +import java.util.List; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.ChatClientCustomizer; + +/** + * Builder for configuring a {@link ChatClient.Builder}. + * + * @author Christian Tzolov + * @author Mark Pollack + * @author Josh Long + * @author Arjen Poutsma + * @since 1.0.0 M1 + */ +public class ChatClientBuilderConfigurer { + + private List customizers; + + void setChatClientCustomizers(List customizers) { + this.customizers = customizers; + } + + /** + * Configure the specified {@link ChatClient.Builder}. The builder can be further + * tuned and default settings can be overridden. + * @param builder the {@link ChatClient.Builder} instance to configure + * @return the configured builder + */ + public ChatClient.Builder configure(ChatClient.Builder builder) { + applyCustomizers(builder); + return builder; + } + + private void applyCustomizers(ChatClient.Builder builder) { + if (this.customizers != null) { + for (ChatClientCustomizer customizer : this.customizers) { + customizer.customize(builder); + } + } + } + +} diff --git a/auto-configurations/chat/spring-ai-chat-client-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/client/ChatClientBuilderProperties.java b/auto-configurations/chat/spring-ai-chat-client-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/client/ChatClientBuilderProperties.java new file mode 100644 index 00000000000..91065c18904 --- /dev/null +++ b/auto-configurations/chat/spring-ai-chat-client-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/client/ChatClientBuilderProperties.java @@ -0,0 +1,71 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.chat.client; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration properties for the chat client builder. + * + * @author Christian Tzolov + * @author Mark Pollack + * @author Josh Long + * @author Arjen Poutsma + * @since 1.0.0 + */ +@ConfigurationProperties(ChatClientBuilderProperties.CONFIG_PREFIX) +public class ChatClientBuilderProperties { + + public static final String CONFIG_PREFIX = "spring.ai.chat.client"; + + /** + * Enable chat client builder. + */ + private boolean enabled = true; + + private Observations observations = new Observations(); + + public Observations getObservations() { + return this.observations; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public static class Observations { + + /** + * Whether to include the input content in the observations. + */ + private boolean includeInput = false; + + public boolean isIncludeInput() { + return this.includeInput; + } + + public void setIncludeInput(boolean includeCompletion) { + this.includeInput = includeCompletion; + } + + } + +} diff --git a/auto-configurations/chat/spring-ai-chat-client-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/chat/spring-ai-chat-client-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..d03329eb4fa --- /dev/null +++ b/auto-configurations/chat/spring-ai-chat-client-spring-boot-autoconfigure/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.autoconfigure.chat.client.ChatClientAutoConfiguration diff --git a/auto-configurations/chat/spring-ai-chat-client-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/client/ChatClientObservationAutoConfigurationTests.java b/auto-configurations/chat/spring-ai-chat-client-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/client/ChatClientObservationAutoConfigurationTests.java new file mode 100644 index 00000000000..1a97de7c756 --- /dev/null +++ b/auto-configurations/chat/spring-ai-chat-client-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/client/ChatClientObservationAutoConfigurationTests.java @@ -0,0 +1,49 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.chat.client; + +import org.junit.jupiter.api.Test; + +import org.springframework.ai.chat.client.observation.ChatClientInputContentObservationFilter; +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 {@link ChatClientAutoConfiguration} observability support. + * + * @author Christian Tzolov + */ +class ChatClientObservationAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(ChatClientAutoConfiguration.class)); + + @Test + void inputContentFilterDefault() { + this.contextRunner + .run(context -> assertThat(context).doesNotHaveBean(ChatClientInputContentObservationFilter.class)); + } + + @Test + void inputContentFilterEnabled() { + this.contextRunner.withPropertyValues("spring.ai.chat.client.observations.include-input=true") + .run(context -> assertThat(context).hasSingleBean(ChatClientInputContentObservationFilter.class)); + } + +} diff --git a/auto-configurations/chat/spring-ai-chat-memory-spring-boot-autoconfigure/pom.xml b/auto-configurations/chat/spring-ai-chat-memory-spring-boot-autoconfigure/pom.xml new file mode 100644 index 00000000000..48b114df6c1 --- /dev/null +++ b/auto-configurations/chat/spring-ai-chat-memory-spring-boot-autoconfigure/pom.xml @@ -0,0 +1,98 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../../pom.xml + + spring-ai-chat-memory-spring-boot-autoconfigure + jar + Spring AI Chat Memory Auto Configuration + Spring AI Chat Memory 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-core + ${parent.version} + + + + org.springframework.ai + spring-ai-cassandra-store + ${parent.version} + + + + + org.springframework.boot + spring-boot-starter + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + + org.springframework.ai + spring-ai-test + ${project.parent.version} + test + + + + org.springframework.ai + spring-ai-chat-client-spring-boot-autoconfigure + ${project.parent.version} + test + + + + org.springframework.ai + spring-ai-openai + ${project.parent.version} + test + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.testcontainers + junit-jupiter + test + + + + org.testcontainers + cassandra + test + + + + org.mockito + mockito-core + test + + + + diff --git a/auto-configurations/chat/spring-ai-chat-memory-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/memory/CommonChatMemoryProperties.java b/auto-configurations/chat/spring-ai-chat-memory-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/memory/CommonChatMemoryProperties.java new file mode 100644 index 00000000000..489ee2acff6 --- /dev/null +++ b/auto-configurations/chat/spring-ai-chat-memory-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/memory/CommonChatMemoryProperties.java @@ -0,0 +1,37 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.chat.memory; + +/** + * Configuration properties for the common chat memory. + * + * @author Mick Semb Wever + * @since 1.0.0 + */ +public class CommonChatMemoryProperties { + + private boolean initializeSchema = true; + + public boolean isInitializeSchema() { + return this.initializeSchema; + } + + public void setInitializeSchema(boolean initializeSchema) { + this.initializeSchema = initializeSchema; + } + +} diff --git a/auto-configurations/chat/spring-ai-chat-memory-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/memory/cassandra/CassandraChatMemoryAutoConfiguration.java b/auto-configurations/chat/spring-ai-chat-memory-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/memory/cassandra/CassandraChatMemoryAutoConfiguration.java new file mode 100644 index 00000000000..2a2ef509dd3 --- /dev/null +++ b/auto-configurations/chat/spring-ai-chat-memory-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/memory/cassandra/CassandraChatMemoryAutoConfiguration.java @@ -0,0 +1,63 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.chat.memory.cassandra; + +import com.datastax.oss.driver.api.core.CqlSession; + +import org.springframework.ai.chat.memory.cassandra.CassandraChatMemory; +import org.springframework.ai.chat.memory.cassandra.CassandraChatMemoryConfig; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.cassandra.CassandraAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +/** + * {@link AutoConfiguration Auto-configuration} for {@link CassandraChatMemory}. + * + * @author Mick Semb Wever + * @author Jihoon Kim + * @since 1.0.0 + */ +@AutoConfiguration(after = CassandraAutoConfiguration.class) +@ConditionalOnClass({ CassandraChatMemory.class, CqlSession.class }) +@EnableConfigurationProperties(CassandraChatMemoryProperties.class) +public class CassandraChatMemoryAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public CassandraChatMemory chatMemory(CassandraChatMemoryProperties properties, CqlSession cqlSession) { + + var builder = CassandraChatMemoryConfig.builder().withCqlSession(cqlSession); + + builder = builder.withKeyspaceName(properties.getKeyspace()) + .withTableName(properties.getTable()) + .withAssistantColumnName(properties.getAssistantColumn()) + .withUserColumnName(properties.getUserColumn()); + + if (!properties.isInitializeSchema()) { + builder = builder.disallowSchemaChanges(); + } + if (null != properties.getTimeToLive()) { + builder = builder.withTimeToLive(properties.getTimeToLive()); + } + + return CassandraChatMemory.create(builder.build()); + } + +} diff --git a/auto-configurations/chat/spring-ai-chat-memory-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/memory/cassandra/CassandraChatMemoryProperties.java b/auto-configurations/chat/spring-ai-chat-memory-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/memory/cassandra/CassandraChatMemoryProperties.java new file mode 100644 index 00000000000..96a4c4ee325 --- /dev/null +++ b/auto-configurations/chat/spring-ai-chat-memory-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/memory/cassandra/CassandraChatMemoryProperties.java @@ -0,0 +1,94 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.chat.memory.cassandra; + +import java.time.Duration; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.autoconfigure.chat.memory.CommonChatMemoryProperties; +import org.springframework.ai.chat.memory.cassandra.CassandraChatMemoryConfig; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.lang.Nullable; + +/** + * Configuration properties for Cassandra chat memory. + * + * @author Mick Semb Wever + * @author Jihoon Kim + * @since 1.0.0 + */ +@ConfigurationProperties(CassandraChatMemoryProperties.CONFIG_PREFIX) +public class CassandraChatMemoryProperties extends CommonChatMemoryProperties { + + public static final String CONFIG_PREFIX = "spring.ai.chat.memory.cassandra"; + + private static final Logger logger = LoggerFactory.getLogger(CassandraChatMemoryProperties.class); + + private String keyspace = CassandraChatMemoryConfig.DEFAULT_KEYSPACE_NAME; + + private String table = CassandraChatMemoryConfig.DEFAULT_TABLE_NAME; + + private String assistantColumn = CassandraChatMemoryConfig.DEFAULT_ASSISTANT_COLUMN_NAME; + + private String userColumn = CassandraChatMemoryConfig.DEFAULT_USER_COLUMN_NAME; + + private Duration timeToLive = null; + + public String getKeyspace() { + return this.keyspace; + } + + public void setKeyspace(String keyspace) { + this.keyspace = keyspace; + } + + public String getTable() { + return this.table; + } + + public void setTable(String table) { + this.table = table; + } + + public String getAssistantColumn() { + return this.assistantColumn; + } + + public void setAssistantColumn(String assistantColumn) { + this.assistantColumn = assistantColumn; + } + + public String getUserColumn() { + return this.userColumn; + } + + public void setUserColumn(String userColumn) { + this.userColumn = userColumn; + } + + @Nullable + public Duration getTimeToLive() { + return this.timeToLive; + } + + public void setTimeToLive(Duration timeToLive) { + this.timeToLive = timeToLive; + } + +} diff --git a/auto-configurations/chat/spring-ai-chat-memory-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/chat/spring-ai-chat-memory-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..9b49bd523f4 --- /dev/null +++ b/auto-configurations/chat/spring-ai-chat-memory-spring-boot-autoconfigure/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.autoconfigure.chat.memory.cassandra.CassandraChatMemoryAutoConfiguration diff --git a/auto-configurations/chat/spring-ai-chat-memory-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/cassandra/CassandraChatMemoryAutoConfigurationIT.java b/auto-configurations/chat/spring-ai-chat-memory-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/cassandra/CassandraChatMemoryAutoConfigurationIT.java new file mode 100644 index 00000000000..982c5737a30 --- /dev/null +++ b/auto-configurations/chat/spring-ai-chat-memory-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/cassandra/CassandraChatMemoryAutoConfigurationIT.java @@ -0,0 +1,124 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.chat.cassandra; + +import java.time.Duration; +import java.util.List; + +import com.datastax.driver.core.utils.UUIDs; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.CassandraContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import org.springframework.ai.autoconfigure.chat.memory.cassandra.CassandraChatMemoryAutoConfiguration; +import org.springframework.ai.autoconfigure.chat.memory.cassandra.CassandraChatMemoryProperties; +import org.springframework.ai.chat.memory.cassandra.CassandraChatMemory; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.MessageType; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.cassandra.CassandraAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Mick Semb Wever + * @author Jihoon Kim + * @since 1.0.0 + */ +@Testcontainers +class CassandraChatMemoryAutoConfigurationIT { + + static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("cassandra"); + + @Container + static CassandraContainer cassandraContainer = new CassandraContainer(DEFAULT_IMAGE_NAME.withTag("5.0")); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(CassandraChatMemoryAutoConfiguration.class, CassandraAutoConfiguration.class)) + .withPropertyValues("spring.ai.chat.memory.cassandra.keyspace=test_autoconfigure"); + + @Test + void addAndGet() { + this.contextRunner.withPropertyValues("spring.cassandra.contactPoints=" + getContactPointHost()) + .withPropertyValues("spring.cassandra.port=" + getContactPointPort()) + .withPropertyValues("spring.cassandra.localDatacenter=" + cassandraContainer.getLocalDatacenter()) + .withPropertyValues("spring.ai.chat.memory.cassandra.time-to-live=" + getTimeToLive()) + .run(context -> { + CassandraChatMemory memory = context.getBean(CassandraChatMemory.class); + + String sessionId = UUIDs.timeBased().toString(); + assertThat(memory.get(sessionId, Integer.MAX_VALUE)).isEmpty(); + + memory.add(sessionId, new UserMessage("test question")); + + assertThat(memory.get(sessionId, Integer.MAX_VALUE)).hasSize(1); + assertThat(memory.get(sessionId, Integer.MAX_VALUE).get(0).getMessageType()) + .isEqualTo(MessageType.USER); + assertThat(memory.get(sessionId, Integer.MAX_VALUE).get(0).getText()).isEqualTo("test question"); + + memory.clear(sessionId); + assertThat(memory.get(sessionId, Integer.MAX_VALUE)).isEmpty(); + + memory.add(sessionId, List.of(new UserMessage("test question"), new AssistantMessage("test answer"))); + + assertThat(memory.get(sessionId, Integer.MAX_VALUE)).hasSize(2); + assertThat(memory.get(sessionId, Integer.MAX_VALUE).get(1).getMessageType()) + .isEqualTo(MessageType.USER); + assertThat(memory.get(sessionId, Integer.MAX_VALUE).get(1).getText()).isEqualTo("test question"); + assertThat(memory.get(sessionId, Integer.MAX_VALUE).get(0).getMessageType()) + .isEqualTo(MessageType.ASSISTANT); + assertThat(memory.get(sessionId, Integer.MAX_VALUE).get(0).getText()).isEqualTo("test answer"); + + CassandraChatMemoryProperties properties = context.getBean(CassandraChatMemoryProperties.class); + assertThat(properties.getTimeToLive()).isEqualTo(getTimeToLive()); + }); + } + + @Test + void compareTimeToLive_ISO8601Format() { + this.contextRunner.withPropertyValues("spring.cassandra.contactPoints=" + getContactPointHost()) + .withPropertyValues("spring.cassandra.port=" + getContactPointPort()) + .withPropertyValues("spring.cassandra.localDatacenter=" + cassandraContainer.getLocalDatacenter()) + .withPropertyValues("spring.ai.chat.memory.cassandra.time-to-live=" + getTimeToLiveString()) + .run(context -> { + CassandraChatMemoryProperties properties = context.getBean(CassandraChatMemoryProperties.class); + assertThat(properties.getTimeToLive()).isEqualTo(Duration.parse(getTimeToLiveString())); + }); + } + + private String getContactPointHost() { + return cassandraContainer.getContactPoint().getHostString(); + } + + private String getContactPointPort() { + return String.valueOf(cassandraContainer.getContactPoint().getPort()); + } + + private Duration getTimeToLive() { + return Duration.ofSeconds(12000); + } + + private String getTimeToLiveString() { + return "PT1M"; + } + +} diff --git a/auto-configurations/chat/spring-ai-chat-memory-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/cassandra/CassandraChatMemoryPropertiesTest.java b/auto-configurations/chat/spring-ai-chat-memory-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/cassandra/CassandraChatMemoryPropertiesTest.java new file mode 100644 index 00000000000..9a8ecf5862e --- /dev/null +++ b/auto-configurations/chat/spring-ai-chat-memory-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/cassandra/CassandraChatMemoryPropertiesTest.java @@ -0,0 +1,64 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.chat.cassandra; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; + +import org.springframework.ai.autoconfigure.chat.memory.cassandra.CassandraChatMemoryProperties; +import org.springframework.ai.chat.memory.cassandra.CassandraChatMemoryConfig; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Mick Semb Wever + * @author Jihoon Kim + * @since 1.0.0 + */ +class CassandraChatMemoryPropertiesTest { + + @Test + void defaultValues() { + var props = new CassandraChatMemoryProperties(); + assertThat(props.getKeyspace()).isEqualTo(CassandraChatMemoryConfig.DEFAULT_KEYSPACE_NAME); + assertThat(props.getTable()).isEqualTo(CassandraChatMemoryConfig.DEFAULT_TABLE_NAME); + assertThat(props.getAssistantColumn()).isEqualTo(CassandraChatMemoryConfig.DEFAULT_ASSISTANT_COLUMN_NAME); + assertThat(props.getUserColumn()).isEqualTo(CassandraChatMemoryConfig.DEFAULT_USER_COLUMN_NAME); + assertThat(props.getTimeToLive()).isNull(); + assertThat(props.isInitializeSchema()).isTrue(); + } + + @Test + void customValues() { + var props = new CassandraChatMemoryProperties(); + props.setKeyspace("my_keyspace"); + props.setTable("my_table"); + props.setAssistantColumn("my_assistant_column"); + props.setUserColumn("my_user_column"); + props.setTimeToLive(Duration.ofDays(1)); + props.setInitializeSchema(false); + + assertThat(props.getKeyspace()).isEqualTo("my_keyspace"); + assertThat(props.getTable()).isEqualTo("my_table"); + assertThat(props.getAssistantColumn()).isEqualTo("my_assistant_column"); + assertThat(props.getUserColumn()).isEqualTo("my_user_column"); + assertThat(props.getTimeToLive()).isEqualTo(Duration.ofDays(1)); + assertThat(props.isInitializeSchema()).isFalse(); + } + +} diff --git a/auto-configurations/chat/spring-ai-chat-model-spring-boot-autoconfigure/pom.xml b/auto-configurations/chat/spring-ai-chat-model-spring-boot-autoconfigure/pom.xml new file mode 100644 index 00000000000..48d2c389ac0 --- /dev/null +++ b/auto-configurations/chat/spring-ai-chat-model-spring-boot-autoconfigure/pom.xml @@ -0,0 +1,66 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../../pom.xml + + spring-ai-chat-model-spring-boot-autoconfigure + jar + Spring AI Chat Model Auto Configuration + Spring AI Chat Model 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-core + ${parent.version} + + + + + org.springframework.boot + spring-boot-starter + + + + org.springframework.boot + spring-boot-configuration-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/chat/spring-ai-chat-model-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/model/ToolCallingAutoConfiguration.java b/auto-configurations/chat/spring-ai-chat-model-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/model/ToolCallingAutoConfiguration.java new file mode 100644 index 00000000000..a530bf7dc5a --- /dev/null +++ b/auto-configurations/chat/spring-ai-chat-model-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/model/ToolCallingAutoConfiguration.java @@ -0,0 +1,87 @@ +/* + * 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.autoconfigure.chat.model; + +import java.util.ArrayList; +import java.util.List; + +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.model.function.FunctionCallback; +import org.springframework.ai.model.tool.ToolCallingManager; +import org.springframework.ai.tool.ToolCallbackProvider; +import org.springframework.ai.tool.execution.DefaultToolExecutionExceptionProcessor; +import org.springframework.ai.tool.execution.ToolExecutionExceptionProcessor; +import org.springframework.ai.tool.resolution.DelegatingToolCallbackResolver; +import org.springframework.ai.tool.resolution.SpringBeanToolCallbackResolver; +import org.springframework.ai.tool.resolution.StaticToolCallbackResolver; +import org.springframework.ai.tool.resolution.ToolCallbackResolver; +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.context.annotation.Bean; +import org.springframework.context.support.GenericApplicationContext; + +/** + * Auto-configuration for common tool calling features of {@link ChatModel}. + * + * @author Thomas Vitale + * @author Christian Tzolov + * @since 1.0.0 + */ +@AutoConfiguration +@ConditionalOnClass(ChatModel.class) +public class ToolCallingAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + ToolCallbackResolver toolCallbackResolver(GenericApplicationContext applicationContext, + List functionCallbacks, List tcbProviders) { + + List allFunctionAndToolCallbacks = new ArrayList<>(functionCallbacks); + tcbProviders.stream().map(pr -> List.of(pr.getToolCallbacks())).forEach(allFunctionAndToolCallbacks::addAll); + + var staticToolCallbackResolver = new StaticToolCallbackResolver(allFunctionAndToolCallbacks); + + var springBeanToolCallbackResolver = SpringBeanToolCallbackResolver.builder() + .applicationContext(applicationContext) + .build(); + + return new DelegatingToolCallbackResolver(List.of(staticToolCallbackResolver, springBeanToolCallbackResolver)); + } + + @Bean + @ConditionalOnMissingBean + ToolExecutionExceptionProcessor toolExecutionExceptionProcessor() { + return new DefaultToolExecutionExceptionProcessor(false); + } + + @Bean + @ConditionalOnMissingBean + ToolCallingManager toolCallingManager(ToolCallbackResolver toolCallbackResolver, + ToolExecutionExceptionProcessor toolExecutionExceptionProcessor, + ObjectProvider observationRegistry) { + return ToolCallingManager.builder() + .observationRegistry(observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)) + .toolCallbackResolver(toolCallbackResolver) + .toolExecutionExceptionProcessor(toolExecutionExceptionProcessor) + .build(); + } + +} diff --git a/auto-configurations/chat/spring-ai-chat-model-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/chat/spring-ai-chat-model-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..5fc47ed68d1 --- /dev/null +++ b/auto-configurations/chat/spring-ai-chat-model-spring-boot-autoconfigure/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.autoconfigure.chat.model.ToolCallingAutoConfiguration diff --git a/auto-configurations/chat/spring-ai-chat-model-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/model/ToolCallingAutoConfigurationTests.java b/auto-configurations/chat/spring-ai-chat-model-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/model/ToolCallingAutoConfigurationTests.java new file mode 100644 index 00000000000..3c6cca70d08 --- /dev/null +++ b/auto-configurations/chat/spring-ai-chat-model-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/model/ToolCallingAutoConfigurationTests.java @@ -0,0 +1,193 @@ +/* + * 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.autoconfigure.chat.model; + +import java.util.function.Function; + +import org.junit.jupiter.api.Test; + +import org.springframework.ai.model.function.FunctionCallback; +import org.springframework.ai.model.tool.DefaultToolCallingManager; +import org.springframework.ai.model.tool.ToolCallingManager; +import org.springframework.ai.tool.StaticToolCallbackProvider; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.ToolCallbackProvider; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.definition.ToolDefinition; +import org.springframework.ai.tool.execution.DefaultToolExecutionExceptionProcessor; +import org.springframework.ai.tool.execution.ToolExecutionExceptionProcessor; +import org.springframework.ai.tool.function.FunctionToolCallback; +import org.springframework.ai.tool.method.MethodToolCallback; +import org.springframework.ai.tool.method.MethodToolCallbackProvider; +import org.springframework.ai.tool.resolution.DelegatingToolCallbackResolver; +import org.springframework.ai.tool.resolution.ToolCallbackResolver; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Description; +import org.springframework.util.ReflectionUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link ToolCallingAutoConfiguration}. + * + * @author Thomas Vitale + * @author Christian Tzolov + */ +class ToolCallingAutoConfigurationTests { + + @Test + void beansAreCreated() { + new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(ToolCallingAutoConfiguration.class)) + .run(context -> { + var toolCallbackResolver = context.getBean(ToolCallbackResolver.class); + assertThat(toolCallbackResolver).isInstanceOf(DelegatingToolCallbackResolver.class); + + var toolExecutionExceptionProcessor = context.getBean(ToolExecutionExceptionProcessor.class); + assertThat(toolExecutionExceptionProcessor).isInstanceOf(DefaultToolExecutionExceptionProcessor.class); + + var toolCallingManager = context.getBean(ToolCallingManager.class); + assertThat(toolCallingManager).isInstanceOf(DefaultToolCallingManager.class); + }); + } + + @Test + void resolveMultipleFuncitonAndToolCallbacks() { + new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(ToolCallingAutoConfiguration.class)) + .withUserConfiguration(Config.class) + .run(context -> { + var toolCallbackResolver = context.getBean(ToolCallbackResolver.class); + assertThat(toolCallbackResolver).isInstanceOf(DelegatingToolCallbackResolver.class); + + assertThat(toolCallbackResolver.resolve("getForecast")).isNotNull(); + assertThat(toolCallbackResolver.resolve("getForecast").getName()).isEqualTo("getForecast"); + + assertThat(toolCallbackResolver.resolve("getAlert")).isNotNull(); + assertThat(toolCallbackResolver.resolve("getAlert").getName()).isEqualTo("getAlert"); + + assertThat(toolCallbackResolver.resolve("weatherFunction1")).isNotNull(); + assertThat(toolCallbackResolver.resolve("weatherFunction1").getName()).isEqualTo("weatherFunction1"); + + assertThat(toolCallbackResolver.resolve("getCurrentWeather3")).isNotNull(); + assertThat(toolCallbackResolver.resolve("getCurrentWeather3").getName()) + .isEqualTo("getCurrentWeather3"); + + assertThat(toolCallbackResolver.resolve("getCurrentWeather4")).isNotNull(); + assertThat(toolCallbackResolver.resolve("getCurrentWeather4").getName()) + .isEqualTo("getCurrentWeather4"); + + assertThat(toolCallbackResolver.resolve("getCurrentWeather5")).isNotNull(); + assertThat(toolCallbackResolver.resolve("getCurrentWeather5").getName()) + .isEqualTo("getCurrentWeather5"); + }); + } + + static class WeatherService { + + @Tool(description = "Get the weather in location. Return temperature in 36°F or 36°C format.") + public String getForecast(String location) { + return "30"; + } + + @Tool(description = "Get the weather in location. Return temperature in 36°F or 36°C format.") + public String getForecast2(String location) { + return "30"; + } + + public String getAlert(String usState) { + return "Alert"; + } + + } + + @Configuration + static class Config { + + // Note: Currently we do not have ToolCallbackResolver implementation that can + // resolve the ToolCallback from the Tool annotation. + // Therefore we need to provide the ToolCallback instances explicitly using the + // ToolCallbacks.from(...) utility method. + @Bean + public ToolCallbackProvider toolCallbacks() { + return MethodToolCallbackProvider.builder().toolObjects(new WeatherService()).build(); + } + + public record Request(String location) { + } + + public record Response(String temperature) { + } + + @Bean + @Description("Get the weather in location. Return temperature in 36°F or 36°C format.") + public Function weatherFunction1() { + return request -> new Response("30"); + } + + @Bean + public FunctionCallback functionCallbacks3() { + return FunctionCallback.builder() + .function("getCurrentWeather3", (Request request) -> "15.0°C") + .description("Gets the weather in location") + .inputType(Request.class) + .build(); + } + + @Bean + public FunctionCallback functionCallbacks4() { + return FunctionCallback.builder() + .function("getCurrentWeather4", (Request request) -> "15.0°C") + .description("Gets the weather in location") + .inputType(Request.class) + .build(); + + } + + @Bean + public ToolCallback toolCallbacks5() { + return FunctionToolCallback.builder("getCurrentWeather5", (Request request) -> "15.0°C") + .description("Gets the weather in location") + .inputType(Request.class) + .build(); + + } + + @Bean + public ToolCallbackProvider blabla() { + return new StaticToolCallbackProvider( + FunctionToolCallback.builder("getCurrentWeather5", (Request request) -> "15.0°C") + .description("Gets the weather in location") + .inputType(Request.class) + .build()); + + } + + @Bean + public ToolCallback toolCallbacks6() { + var toolMethod = ReflectionUtils.findMethod(WeatherService.class, "getAlert", String.class); + return MethodToolCallback.builder() + .toolDefinition(ToolDefinition.builder(toolMethod).build()) + .toolMethod(toolMethod) + .toolObject(new WeatherService()) + .build(); + } + + } + +} diff --git a/auto-configurations/common/spring-ai-retry-spring-boot-autoconfigure/pom.xml b/auto-configurations/common/spring-ai-retry-spring-boot-autoconfigure/pom.xml new file mode 100644 index 00000000000..3c2bda17113 --- /dev/null +++ b/auto-configurations/common/spring-ai-retry-spring-boot-autoconfigure/pom.xml @@ -0,0 +1,66 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../../pom.xml + + spring-ai-retry-spring-boot-autoconfigure + jar + Spring AI Retry Auto Configuration + Spring AI Retry 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-retry + ${parent.version} + + + + + org.springframework.boot + spring-boot-starter + + + + org.springframework.boot + spring-boot-configuration-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/common/spring-ai-retry-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/retry/SpringAiRetryAutoConfiguration.java b/auto-configurations/common/spring-ai-retry-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/retry/SpringAiRetryAutoConfiguration.java new file mode 100644 index 00000000000..b17b4bfc871 --- /dev/null +++ b/auto-configurations/common/spring-ai-retry-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/retry/SpringAiRetryAutoConfiguration.java @@ -0,0 +1,114 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.retry; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.retry.NonTransientAiException; +import org.springframework.ai.retry.RetryUtils; +import org.springframework.ai.retry.TransientAiException; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.lang.NonNull; +import org.springframework.retry.RetryCallback; +import org.springframework.retry.RetryContext; +import org.springframework.retry.RetryListener; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StreamUtils; +import org.springframework.web.client.ResponseErrorHandler; + +/** + * {@link AutoConfiguration Auto-configuration} for AI Retry. + * + * @author Christian Tzolov + */ +@AutoConfiguration +@ConditionalOnClass(RetryUtils.class) +@EnableConfigurationProperties({ SpringAiRetryProperties.class }) +public class SpringAiRetryAutoConfiguration { + + private static final Logger logger = LoggerFactory.getLogger(SpringAiRetryAutoConfiguration.class); + + @Bean + @ConditionalOnMissingBean + public RetryTemplate retryTemplate(SpringAiRetryProperties properties) { + return RetryTemplate.builder() + .maxAttempts(properties.getMaxAttempts()) + .retryOn(TransientAiException.class) + .exponentialBackoff(properties.getBackoff().getInitialInterval(), properties.getBackoff().getMultiplier(), + properties.getBackoff().getMaxInterval()) + .withListener(new RetryListener() { + + @Override + public void onError(RetryContext context, + RetryCallback callback, Throwable throwable) { + logger.warn("Retry error. Retry count:" + context.getRetryCount(), throwable); + } + }) + .build(); + } + + @Bean + @ConditionalOnMissingBean + public ResponseErrorHandler responseErrorHandler(SpringAiRetryProperties properties) { + + return new ResponseErrorHandler() { + + @Override + public boolean hasError(@NonNull ClientHttpResponse response) throws IOException { + return response.getStatusCode().isError(); + } + + @Override + public void handleError(@NonNull ClientHttpResponse response) throws IOException { + if (response.getStatusCode().isError()) { + String error = StreamUtils.copyToString(response.getBody(), StandardCharsets.UTF_8); + String message = String.format("%s - %s", response.getStatusCode().value(), error); + + // Explicitly configured transient codes + if (properties.getOnHttpCodes().contains(response.getStatusCode().value())) { + throw new TransientAiException(message); + } + + // onClientErrors - If true, do not throw a NonTransientAiException, + // and do not attempt retry for 4xx client error codes, false by + // default. + if (!properties.isOnClientErrors() && response.getStatusCode().is4xxClientError()) { + throw new NonTransientAiException(message); + } + + // Explicitly configured non-transient codes + if (!CollectionUtils.isEmpty(properties.getExcludeOnHttpCodes()) + && properties.getExcludeOnHttpCodes().contains(response.getStatusCode().value())) { + throw new NonTransientAiException(message); + } + throw new TransientAiException(message); + } + } + }; + } + +} diff --git a/auto-configurations/common/spring-ai-retry-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/retry/SpringAiRetryProperties.java b/auto-configurations/common/spring-ai-retry-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/retry/SpringAiRetryProperties.java new file mode 100644 index 00000000000..f9e681f0d64 --- /dev/null +++ b/auto-configurations/common/spring-ai-retry-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/retry/SpringAiRetryProperties.java @@ -0,0 +1,147 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.retry; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * Properties for AI Retry. + * + * @author Christian Tzolov + */ +@ConfigurationProperties(SpringAiRetryProperties.CONFIG_PREFIX) +public class SpringAiRetryProperties { + + public static final String CONFIG_PREFIX = "spring.ai.retry"; + + /** + * Maximum number of retry attempts. + */ + private int maxAttempts = 10; + + /** + * Exponential Backoff properties. + */ + @NestedConfigurationProperty + private Backoff backoff = new Backoff(); + + /** + * If false, throw a NonTransientAiException, and do not attempt retry for 4xx client + * error codes. False by default. If true, throw a TransientAiException, and attempt + * retry for 4xx client. + */ + private boolean onClientErrors = false; + + /** + * List of HTTP status codes that should not trigger a retry (e.g. throw + * NonTransientAiException). + */ + private List excludeOnHttpCodes = new ArrayList<>(); + + /** + * List of HTTP status codes that should trigger a retry. + */ + private List onHttpCodes = new ArrayList<>(); + + public int getMaxAttempts() { + return this.maxAttempts; + } + + public void setMaxAttempts(int maxAttempts) { + this.maxAttempts = maxAttempts; + } + + public Backoff getBackoff() { + return this.backoff; + } + + public List getExcludeOnHttpCodes() { + return this.excludeOnHttpCodes; + } + + public void setExcludeOnHttpCodes(List onHttpCodes) { + this.excludeOnHttpCodes = onHttpCodes; + } + + public boolean isOnClientErrors() { + return this.onClientErrors; + } + + public void setOnClientErrors(boolean onClientErrors) { + this.onClientErrors = onClientErrors; + } + + public List getOnHttpCodes() { + return this.onHttpCodes; + } + + public void setOnHttpCodes(List onHttpCodes) { + this.onHttpCodes = onHttpCodes; + } + + /** + * Exponential Backoff properties. + */ + public static class Backoff { + + /** + * Initial sleep duration. + */ + private Duration initialInterval = Duration.ofMillis(2000); + + /** + * Backoff interval multiplier. + */ + private int multiplier = 5; + + /** + * Maximum backoff duration. + */ + private Duration maxInterval = Duration.ofMillis(3 * 60000); + + public Duration getInitialInterval() { + return this.initialInterval; + } + + public void setInitialInterval(Duration initialInterval) { + this.initialInterval = initialInterval; + } + + public int getMultiplier() { + return this.multiplier; + } + + public void setMultiplier(int multiplier) { + this.multiplier = multiplier; + } + + public Duration getMaxInterval() { + return this.maxInterval; + } + + public void setMaxInterval(Duration maxInterval) { + this.maxInterval = maxInterval; + } + + } + +} diff --git a/auto-configurations/common/spring-ai-retry-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/common/spring-ai-retry-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..e08dd4f6ce1 --- /dev/null +++ b/auto-configurations/common/spring-ai-retry-spring-boot-autoconfigure/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.autoconfigure.retry.SpringAiRetryAutoConfiguration diff --git a/auto-configurations/common/spring-ai-retry-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/retry/SpringAiRetryAutoConfigurationIT.java b/auto-configurations/common/spring-ai-retry-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/retry/SpringAiRetryAutoConfigurationIT.java new file mode 100644 index 00000000000..0c8dba03b7f --- /dev/null +++ b/auto-configurations/common/spring-ai-retry-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/retry/SpringAiRetryAutoConfigurationIT.java @@ -0,0 +1,45 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.retry; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.web.client.ResponseErrorHandler; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Christian Tzolov + */ +public class SpringAiRetryAutoConfigurationIT { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().withConfiguration( + AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, RestClientAutoConfiguration.class)); + + @Test + void testRetryAutoConfiguration() { + this.contextRunner.run(context -> { + assertThat(context).hasSingleBean(RetryTemplate.class); + assertThat(context).hasSingleBean(ResponseErrorHandler.class); + }); + } + +} diff --git a/auto-configurations/common/spring-ai-retry-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/retry/SpringAiRetryPropertiesTests.java b/auto-configurations/common/spring-ai-retry-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/retry/SpringAiRetryPropertiesTests.java new file mode 100644 index 00000000000..3f20e19f8db --- /dev/null +++ b/auto-configurations/common/spring-ai-retry-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/retry/SpringAiRetryPropertiesTests.java @@ -0,0 +1,78 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.retry; + +import org.junit.jupiter.api.Test; + +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 {@link SpringAiRetryProperties}. + * + * @author Christian Tzolov + */ +public class SpringAiRetryPropertiesTests { + + @Test + public void retryDefaultProperties() { + + new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class)) + .run(context -> { + var retryProperties = context.getBean(SpringAiRetryProperties.class); + + assertThat(retryProperties.getMaxAttempts()).isEqualTo(10); + // do not retry on 4xx errors + assertThat(retryProperties.isOnClientErrors()).isFalse(); + assertThat(retryProperties.getExcludeOnHttpCodes()).isEmpty(); + assertThat(retryProperties.getOnHttpCodes()).isEmpty(); + assertThat(retryProperties.getBackoff().getInitialInterval().toMillis()).isEqualTo(2000); + assertThat(retryProperties.getBackoff().getMultiplier()).isEqualTo(5); + assertThat(retryProperties.getBackoff().getMaxInterval().toMillis()).isEqualTo(3 * 60000); + }); + } + + @Test + public void retryCustomProperties() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.retry.max-attempts=100", + "spring.ai.retry.on-client-errors=false", + "spring.ai.retry.exclude-on-http-codes=404,500", + "spring.ai.retry.on-http-codes=429", + "spring.ai.retry.backoff.initial-interval=1000", + "spring.ai.retry.backoff.multiplier=2", + "spring.ai.retry.backoff.max-interval=60000") + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class)) + .run(context -> { + var retryProperties = context.getBean(SpringAiRetryProperties.class); + + assertThat(retryProperties.getMaxAttempts()).isEqualTo(100); + assertThat(retryProperties.isOnClientErrors()).isFalse(); + assertThat(retryProperties.getExcludeOnHttpCodes()).containsExactly(404, 500); + assertThat(retryProperties.getOnHttpCodes()).containsExactly(429); + assertThat(retryProperties.getBackoff().getInitialInterval().toMillis()).isEqualTo(1000); + assertThat(retryProperties.getBackoff().getMultiplier()).isEqualTo(2); + assertThat(retryProperties.getBackoff().getMaxInterval().toMillis()).isEqualTo(60000); + }); + } + +} diff --git a/auto-configurations/spring-ai-mcp-client/pom.xml b/auto-configurations/mcp/spring-ai-mcp-client/pom.xml similarity index 98% rename from auto-configurations/spring-ai-mcp-client/pom.xml rename to auto-configurations/mcp/spring-ai-mcp-client/pom.xml index 0b2f8ce40d9..61d3e56ee79 100644 --- a/auto-configurations/spring-ai-mcp-client/pom.xml +++ b/auto-configurations/mcp/spring-ai-mcp-client/pom.xml @@ -7,7 +7,7 @@ org.springframework.ai spring-ai 1.0.0-SNAPSHOT - ../../pom.xml + ../../../pom.xml spring-ai-mcp-client-spring-boot-autoconfigure jar diff --git a/auto-configurations/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/McpClientAutoConfiguration.java b/auto-configurations/mcp/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/McpClientAutoConfiguration.java similarity index 100% rename from auto-configurations/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/McpClientAutoConfiguration.java rename to auto-configurations/mcp/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/McpClientAutoConfiguration.java diff --git a/auto-configurations/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/NamedClientMcpTransport.java b/auto-configurations/mcp/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/NamedClientMcpTransport.java similarity index 100% rename from auto-configurations/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/NamedClientMcpTransport.java rename to auto-configurations/mcp/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/NamedClientMcpTransport.java diff --git a/auto-configurations/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/SseHttpClientTransportAutoConfiguration.java b/auto-configurations/mcp/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/SseHttpClientTransportAutoConfiguration.java similarity index 100% rename from auto-configurations/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/SseHttpClientTransportAutoConfiguration.java rename to auto-configurations/mcp/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/SseHttpClientTransportAutoConfiguration.java diff --git a/auto-configurations/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/SseWebFluxTransportAutoConfiguration.java b/auto-configurations/mcp/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/SseWebFluxTransportAutoConfiguration.java similarity index 100% rename from auto-configurations/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/SseWebFluxTransportAutoConfiguration.java rename to auto-configurations/mcp/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/SseWebFluxTransportAutoConfiguration.java diff --git a/auto-configurations/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/StdioTransportAutoConfiguration.java b/auto-configurations/mcp/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/StdioTransportAutoConfiguration.java similarity index 100% rename from auto-configurations/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/StdioTransportAutoConfiguration.java rename to auto-configurations/mcp/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/StdioTransportAutoConfiguration.java diff --git a/auto-configurations/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/configurer/McpAsyncClientConfigurer.java b/auto-configurations/mcp/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/configurer/McpAsyncClientConfigurer.java similarity index 100% rename from auto-configurations/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/configurer/McpAsyncClientConfigurer.java rename to auto-configurations/mcp/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/configurer/McpAsyncClientConfigurer.java diff --git a/auto-configurations/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/configurer/McpSyncClientConfigurer.java b/auto-configurations/mcp/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/configurer/McpSyncClientConfigurer.java similarity index 100% rename from auto-configurations/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/configurer/McpSyncClientConfigurer.java rename to auto-configurations/mcp/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/configurer/McpSyncClientConfigurer.java diff --git a/auto-configurations/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/properties/McpClientCommonProperties.java b/auto-configurations/mcp/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/properties/McpClientCommonProperties.java similarity index 100% rename from auto-configurations/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/properties/McpClientCommonProperties.java rename to auto-configurations/mcp/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/properties/McpClientCommonProperties.java diff --git a/auto-configurations/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/properties/McpSseClientProperties.java b/auto-configurations/mcp/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/properties/McpSseClientProperties.java similarity index 100% rename from auto-configurations/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/properties/McpSseClientProperties.java rename to auto-configurations/mcp/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/properties/McpSseClientProperties.java diff --git a/auto-configurations/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/properties/McpStdioClientProperties.java b/auto-configurations/mcp/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/properties/McpStdioClientProperties.java similarity index 100% rename from auto-configurations/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/properties/McpStdioClientProperties.java rename to auto-configurations/mcp/spring-ai-mcp-client/src/main/java/org/springframework/ai/autoconfigure/mcp/client/properties/McpStdioClientProperties.java diff --git a/auto-configurations/spring-ai-mcp-client/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/mcp/spring-ai-mcp-client/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports similarity index 100% rename from auto-configurations/spring-ai-mcp-client/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports rename to auto-configurations/mcp/spring-ai-mcp-client/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports diff --git a/auto-configurations/spring-ai-mcp-client/src/test/java/org/springframework/ai/autoconfigure/mcp/client/McpClientAutoConfigurationIT.java b/auto-configurations/mcp/spring-ai-mcp-client/src/test/java/org/springframework/ai/autoconfigure/mcp/client/McpClientAutoConfigurationIT.java similarity index 100% rename from auto-configurations/spring-ai-mcp-client/src/test/java/org/springframework/ai/autoconfigure/mcp/client/McpClientAutoConfigurationIT.java rename to auto-configurations/mcp/spring-ai-mcp-client/src/test/java/org/springframework/ai/autoconfigure/mcp/client/McpClientAutoConfigurationIT.java diff --git a/auto-configurations/spring-ai-mcp-client/src/test/resources/application-test.properties b/auto-configurations/mcp/spring-ai-mcp-client/src/test/resources/application-test.properties similarity index 100% rename from auto-configurations/spring-ai-mcp-client/src/test/resources/application-test.properties rename to auto-configurations/mcp/spring-ai-mcp-client/src/test/resources/application-test.properties diff --git a/auto-configurations/spring-ai-mcp-server/pom.xml b/auto-configurations/mcp/spring-ai-mcp-server/pom.xml similarity index 97% rename from auto-configurations/spring-ai-mcp-server/pom.xml rename to auto-configurations/mcp/spring-ai-mcp-server/pom.xml index 3f4a942e6fa..a68c11af5d9 100644 --- a/auto-configurations/spring-ai-mcp-server/pom.xml +++ b/auto-configurations/mcp/spring-ai-mcp-server/pom.xml @@ -7,7 +7,7 @@ org.springframework.ai spring-ai 1.0.0-SNAPSHOT - ../../pom.xml + ../../../pom.xml spring-ai-mcp-server-spring-boot-autoconfigure jar diff --git a/auto-configurations/spring-ai-mcp-server/src/main/java/org/springframework/ai/autoconfigure/mcp/server/McpServerAutoConfiguration.java b/auto-configurations/mcp/spring-ai-mcp-server/src/main/java/org/springframework/ai/autoconfigure/mcp/server/McpServerAutoConfiguration.java similarity index 100% rename from auto-configurations/spring-ai-mcp-server/src/main/java/org/springframework/ai/autoconfigure/mcp/server/McpServerAutoConfiguration.java rename to auto-configurations/mcp/spring-ai-mcp-server/src/main/java/org/springframework/ai/autoconfigure/mcp/server/McpServerAutoConfiguration.java diff --git a/auto-configurations/spring-ai-mcp-server/src/main/java/org/springframework/ai/autoconfigure/mcp/server/McpServerProperties.java b/auto-configurations/mcp/spring-ai-mcp-server/src/main/java/org/springframework/ai/autoconfigure/mcp/server/McpServerProperties.java similarity index 100% rename from auto-configurations/spring-ai-mcp-server/src/main/java/org/springframework/ai/autoconfigure/mcp/server/McpServerProperties.java rename to auto-configurations/mcp/spring-ai-mcp-server/src/main/java/org/springframework/ai/autoconfigure/mcp/server/McpServerProperties.java diff --git a/auto-configurations/spring-ai-mcp-server/src/main/java/org/springframework/ai/autoconfigure/mcp/server/McpWebFluxServerAutoConfiguration.java b/auto-configurations/mcp/spring-ai-mcp-server/src/main/java/org/springframework/ai/autoconfigure/mcp/server/McpWebFluxServerAutoConfiguration.java similarity index 100% rename from auto-configurations/spring-ai-mcp-server/src/main/java/org/springframework/ai/autoconfigure/mcp/server/McpWebFluxServerAutoConfiguration.java rename to auto-configurations/mcp/spring-ai-mcp-server/src/main/java/org/springframework/ai/autoconfigure/mcp/server/McpWebFluxServerAutoConfiguration.java diff --git a/auto-configurations/spring-ai-mcp-server/src/main/java/org/springframework/ai/autoconfigure/mcp/server/McpWebMvcServerAutoConfiguration.java b/auto-configurations/mcp/spring-ai-mcp-server/src/main/java/org/springframework/ai/autoconfigure/mcp/server/McpWebMvcServerAutoConfiguration.java similarity index 100% rename from auto-configurations/spring-ai-mcp-server/src/main/java/org/springframework/ai/autoconfigure/mcp/server/McpWebMvcServerAutoConfiguration.java rename to auto-configurations/mcp/spring-ai-mcp-server/src/main/java/org/springframework/ai/autoconfigure/mcp/server/McpWebMvcServerAutoConfiguration.java diff --git a/auto-configurations/spring-ai-mcp-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/mcp/spring-ai-mcp-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports similarity index 100% rename from auto-configurations/spring-ai-mcp-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports rename to auto-configurations/mcp/spring-ai-mcp-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports diff --git a/auto-configurations/spring-ai-mcp-server/src/test/java/org/springframework/ai/autoconfigure/mcp/server/McpServerAutoConfigurationIT.java b/auto-configurations/mcp/spring-ai-mcp-server/src/test/java/org/springframework/ai/autoconfigure/mcp/server/McpServerAutoConfigurationIT.java similarity index 100% rename from auto-configurations/spring-ai-mcp-server/src/test/java/org/springframework/ai/autoconfigure/mcp/server/McpServerAutoConfigurationIT.java rename to auto-configurations/mcp/spring-ai-mcp-server/src/test/java/org/springframework/ai/autoconfigure/mcp/server/McpServerAutoConfigurationIT.java diff --git a/auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/pom.xml b/auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/pom.xml new file mode 100644 index 00000000000..f98be6d3232 --- /dev/null +++ b/auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/pom.xml @@ -0,0 +1,93 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../../pom.xml + + spring-ai-anthropic-spring-boot-autoconfigure + jar + Spring AI Anthropic Auto Configuration + Spring AI Anthropic 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-anthropic + ${project.parent.version} + true + + + + + + org.springframework.ai + spring-ai-chat-client-spring-boot-autoconfigure + ${project.parent.version} + true + + + + org.springframework.ai + spring-ai-chat-model-spring-boot-autoconfigure + ${project.parent.version} + true + + + + org.springframework.ai + spring-ai-retry-spring-boot-autoconfigure + ${project.parent.version} + true + + + + + org.springframework.boot + spring-boot-starter + true + + + + org.springframework.boot + spring-boot-configuration-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-anthropic-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/anthropic/AnthropicAutoConfiguration.java b/auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/anthropic/AnthropicAutoConfiguration.java new file mode 100644 index 00000000000..40addc8d69e --- /dev/null +++ b/auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/anthropic/AnthropicAutoConfiguration.java @@ -0,0 +1,102 @@ +/* + * 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.autoconfigure.anthropic; + +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.ai.anthropic.AnthropicChatModel; +import org.springframework.ai.anthropic.api.AnthropicApi; +import org.springframework.ai.autoconfigure.chat.model.ToolCallingAutoConfiguration; +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.chat.observation.ChatModelObservationConvention; +import org.springframework.ai.model.function.DefaultFunctionCallbackResolver; +import org.springframework.ai.model.function.FunctionCallbackResolver; +import org.springframework.ai.model.tool.ToolCallingManager; +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.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.web.client.ResponseErrorHandler; +import org.springframework.web.client.RestClient; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * {@link AutoConfiguration Auto-configuration} for Anthropic Chat Model. + * + * @author Christian Tzolov + * @author Thomas Vitale + * @since 1.0.0 + */ +@AutoConfiguration(after = { RestClientAutoConfiguration.class, SpringAiRetryAutoConfiguration.class, + ToolCallingAutoConfiguration.class }) +@EnableConfigurationProperties({ AnthropicChatProperties.class, AnthropicConnectionProperties.class }) +@ConditionalOnClass(AnthropicApi.class) +@ConditionalOnProperty(prefix = AnthropicChatProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) +@ImportAutoConfiguration(classes = { SpringAiRetryAutoConfiguration.class, RestClientAutoConfiguration.class, + ToolCallingAutoConfiguration.class, WebClientAutoConfiguration.class }) +public class AnthropicAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public AnthropicApi anthropicApi(AnthropicConnectionProperties connectionProperties, + ObjectProvider restClientBuilderProvider, + ObjectProvider webClientBuilderProvider, ResponseErrorHandler responseErrorHandler) { + + return new AnthropicApi(connectionProperties.getBaseUrl(), connectionProperties.getApiKey(), + connectionProperties.getVersion(), restClientBuilderProvider.getIfAvailable(RestClient::builder), + webClientBuilderProvider.getIfAvailable(WebClient::builder), responseErrorHandler, + connectionProperties.getBetaVersion()); + } + + @Bean + @ConditionalOnMissingBean + public AnthropicChatModel anthropicChatModel(AnthropicApi anthropicApi, AnthropicChatProperties chatProperties, + RetryTemplate retryTemplate, ToolCallingManager toolCallingManager, + ObjectProvider observationRegistry, + ObjectProvider observationConvention) { + + var chatModel = AnthropicChatModel.builder() + .anthropicApi(anthropicApi) + .defaultOptions(chatProperties.getOptions()) + .toolCallingManager(toolCallingManager) + .retryTemplate(retryTemplate) + .observationRegistry(observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)) + .build(); + + observationConvention.ifAvailable(chatModel::setObservationConvention); + + return chatModel; + } + + @Bean + @ConditionalOnMissingBean + public FunctionCallbackResolver springAiFunctionManager(ApplicationContext context) { + DefaultFunctionCallbackResolver manager = new DefaultFunctionCallbackResolver(); + manager.setApplicationContext(context); + return manager; + } + +} diff --git a/auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/anthropic/AnthropicChatProperties.java b/auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/anthropic/AnthropicChatProperties.java new file mode 100644 index 00000000000..4e70c916d04 --- /dev/null +++ b/auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/anthropic/AnthropicChatProperties.java @@ -0,0 +1,65 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.anthropic; + +import org.springframework.ai.anthropic.AnthropicChatModel; +import org.springframework.ai.anthropic.AnthropicChatOptions; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * Anthropic Chat autoconfiguration properties. + * + * @author Christian Tzolov + * @author Alexandros Pappas + * @since 1.0.0 + */ +@ConfigurationProperties(AnthropicChatProperties.CONFIG_PREFIX) +public class AnthropicChatProperties { + + public static final String CONFIG_PREFIX = "spring.ai.anthropic.chat"; + + /** + * Enable Anthropic chat model. + */ + private boolean enabled = true; + + /** + * Client lever Ollama options. Use this property to configure generative temperature, + * topK and topP and alike parameters. The null values are ignored defaulting to the + * generative's defaults. + */ + @NestedConfigurationProperty + private AnthropicChatOptions options = AnthropicChatOptions.builder() + .model(AnthropicChatModel.DEFAULT_MODEL_NAME) + .maxTokens(AnthropicChatModel.DEFAULT_MAX_TOKENS) + .temperature(AnthropicChatModel.DEFAULT_TEMPERATURE) + .build(); + + public AnthropicChatOptions getOptions() { + return this.options; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + +} diff --git a/auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/anthropic/AnthropicConnectionProperties.java b/auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/anthropic/AnthropicConnectionProperties.java new file mode 100644 index 00000000000..53a74e35178 --- /dev/null +++ b/auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/anthropic/AnthropicConnectionProperties.java @@ -0,0 +1,86 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.anthropic; + +import org.springframework.ai.anthropic.api.AnthropicApi; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Anthropic API connection properties. + * + * @author Christian Tzolov + * @since 1.0.0 + */ +@ConfigurationProperties(AnthropicConnectionProperties.CONFIG_PREFIX) +public class AnthropicConnectionProperties { + + public static final String CONFIG_PREFIX = "spring.ai.anthropic"; + + /** + * Anthropic API access key. + */ + private String apiKey; + + /** + * Anthropic API base URL. + */ + private String baseUrl = AnthropicApi.DEFAULT_BASE_URL; + + /** + * Anthropic API version. + */ + private String version = AnthropicApi.DEFAULT_ANTHROPIC_VERSION; + + /** + * Beta features version. Such as tools-2024-04-04 or + * max-tokens-3-5-sonnet-2024-07-15. + */ + private String betaVersion = AnthropicApi.DEFAULT_ANTHROPIC_BETA_VERSION; + + 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; + } + + public String getVersion() { + return this.version; + } + + public void setVersion(String version) { + this.version = version; + } + + public String getBetaVersion() { + return this.betaVersion; + } + + public void setBetaVersion(String betaVersion) { + this.betaVersion = betaVersion; + } + +} diff --git a/auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..60b58e80808 --- /dev/null +++ b/auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/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.autoconfigure.anthropic.AnthropicAutoConfiguration diff --git a/auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/anthropic/AnthropicAutoConfigurationIT.java b/auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/anthropic/AnthropicAutoConfigurationIT.java new file mode 100644 index 00000000000..5b41f15aee7 --- /dev/null +++ b/auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/anthropic/AnthropicAutoConfigurationIT.java @@ -0,0 +1,94 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.anthropic; + +import java.util.List; +import java.util.stream.Collectors; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import reactor.core.publisher.Flux; + +import org.springframework.ai.anthropic.AnthropicChatModel; +import org.springframework.ai.anthropic.AnthropicChatOptions; +import org.springframework.ai.anthropic.api.AnthropicApi; +import org.springframework.ai.chat.messages.AssistantMessage; +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.Prompt; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +@EnabledIfEnvironmentVariable(named = "ANTHROPIC_API_KEY", matches = ".*") +public class AnthropicAutoConfigurationIT { + + private static final Log logger = LogFactory.getLog(AnthropicAutoConfigurationIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.anthropic.apiKey=" + System.getenv("ANTHROPIC_API_KEY")) + .withConfiguration(AutoConfigurations.of(AnthropicAutoConfiguration.class)); + + @Test + void call() { + this.contextRunner.run(context -> { + AnthropicChatModel chatModel = context.getBean(AnthropicChatModel.class); + String response = chatModel.call("Hello"); + assertThat(response).isNotEmpty(); + logger.info("Response: " + response); + }); + } + + @Test + void callWith8KResponseContext() { + this.contextRunner + .withPropertyValues("spring.ai.anthropic.beta-version=" + AnthropicApi.BETA_MAX_TOKENS, + "spring.ai.anthropic.chat.options.model=" + AnthropicApi.ChatModel.CLAUDE_3_5_SONNET.getValue()) + .run(context -> { + AnthropicChatModel chatModel = context.getBean(AnthropicChatModel.class); + var optoins = AnthropicChatOptions.builder().maxTokens(8192).build(); + var response = chatModel.call(new Prompt("Tell me a joke", optoins)); + assertThat(response.getResult().getOutput().getText()).isNotEmpty(); + logger.info("Response: " + response); + }); + } + + @Test + void stream() { + this.contextRunner.run(context -> { + AnthropicChatModel chatModel = context.getBean(AnthropicChatModel.class); + Flux responseFlux = chatModel.stream(new Prompt(new UserMessage("Hello"))); + + String response = responseFlux.collectList() + .block() + .stream() + .map(ChatResponse::getResults) + .flatMap(List::stream) + .map(Generation::getOutput) + .map(AssistantMessage::getText) + .collect(Collectors.joining()); + + assertThat(response).isNotEmpty(); + logger.info("Response: " + response); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/anthropic/AnthropicPropertiesTests.java b/auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/anthropic/AnthropicPropertiesTests.java new file mode 100644 index 00000000000..e2909971dd8 --- /dev/null +++ b/auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/anthropic/AnthropicPropertiesTests.java @@ -0,0 +1,132 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.anthropic; + +import org.junit.jupiter.api.Test; + +import org.springframework.ai.anthropic.AnthropicChatModel; +import org.springframework.ai.autoconfigure.retry.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 AnthropicChatProperties}, {@link AnthropicConnectionProperties}. + */ +public class AnthropicPropertiesTests { + + @Test + public void connectionProperties() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.anthropic.base-url=TEST_BASE_URL", + "spring.ai.anthropic.api-key=abc123", + "spring.ai.anthropic.version=6666", + "spring.ai.anthropic.beta-version=7777", + "spring.ai.anthropic.chat.options.model=MODEL_XYZ", + "spring.ai.anthropic.chat.options.temperature=0.55") + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, AnthropicAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(AnthropicChatProperties.class); + var connectionProperties = context.getBean(AnthropicConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + assertThat(connectionProperties.getVersion()).isEqualTo("6666"); + assertThat(connectionProperties.getBetaVersion()).isEqualTo("7777"); + + assertThat(chatProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55); + // enabled is true by default + assertThat(chatProperties.isEnabled()).isTrue(); + }); + } + + @Test + public void chatOptionsTest() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.anthropic.api-key=API_KEY", + "spring.ai.anthropic.base-url=TEST_BASE_URL", + + "spring.ai.anthropic.chat.options.model=MODEL_XYZ", + "spring.ai.anthropic.chat.options.max-tokens=123", + "spring.ai.anthropic.chat.options.metadata.user-id=MyUserId", + "spring.ai.anthropic.chat.options.stop_sequences=boza,koza", + + "spring.ai.anthropic.chat.options.temperature=0.55", + "spring.ai.anthropic.chat.options.top-p=0.56", + "spring.ai.anthropic.chat.options.top-k=100" + ) + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, AnthropicAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(AnthropicChatProperties.class); + var connectionProperties = context.getBean(AnthropicConnectionProperties.class); + + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY"); + assertThat(chatProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(chatProperties.getOptions().getMaxTokens()).isEqualTo(123); + assertThat(chatProperties.getOptions().getStopSequences()).contains("boza", "koza"); + assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55); + assertThat(chatProperties.getOptions().getTopP()).isEqualTo(0.56); + assertThat(chatProperties.getOptions().getTopK()).isEqualTo(100); + + assertThat(chatProperties.getOptions().getMetadata().userId()).isEqualTo("MyUserId"); + }); + } + + @Test + public void chatCompletionDisabled() { + + // It is enabled by default + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, AnthropicAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(AnthropicChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(AnthropicChatModel.class)).isNotEmpty(); + }); + + // Explicitly enable the chat auto-configuration. + new ApplicationContextRunner().withPropertyValues("spring.ai.anthropic.chat.enabled=true") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, AnthropicAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(AnthropicChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(AnthropicChatModel.class)).isNotEmpty(); + }); + + // Explicitly disable the chat auto-configuration. + new ApplicationContextRunner().withPropertyValues("spring.ai.anthropic.chat.enabled=false") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, AnthropicAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(AnthropicChatProperties.class)).isEmpty(); + assertThat(context.getBeansOfType(AnthropicChatModel.class)).isEmpty(); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/anthropic/tool/FunctionCallWithFunctionBeanIT.java b/auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/anthropic/tool/FunctionCallWithFunctionBeanIT.java new file mode 100644 index 00000000000..51e9e39be2f --- /dev/null +++ b/auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/anthropic/tool/FunctionCallWithFunctionBeanIT.java @@ -0,0 +1,126 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.anthropic.tool; + +import java.util.List; +import java.util.function.Function; + +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.anthropic.AnthropicChatModel; +import org.springframework.ai.anthropic.AnthropicChatOptions; +import org.springframework.ai.anthropic.api.AnthropicApi; +import org.springframework.ai.autoconfigure.anthropic.AnthropicAutoConfiguration; +import org.springframework.ai.autoconfigure.anthropic.tool.MockWeatherService.Request; +import org.springframework.ai.autoconfigure.anthropic.tool.MockWeatherService.Response; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.model.function.FunctionCallingOptions; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Description; + +import static org.assertj.core.api.Assertions.assertThat; + +@EnabledIfEnvironmentVariable(named = "ANTHROPIC_API_KEY", matches = ".*") +class FunctionCallWithFunctionBeanIT { + + private final Logger logger = LoggerFactory.getLogger(FunctionCallWithFunctionBeanIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.anthropic.apiKey=" + System.getenv("ANTHROPIC_API_KEY")) + .withConfiguration(AutoConfigurations.of(AnthropicAutoConfiguration.class)) + .withUserConfiguration(Config.class); + + @Test + void functionCallTest() { + + this.contextRunner + .withPropertyValues( + "spring.ai.anthropic.chat.options.model=" + AnthropicApi.ChatModel.CLAUDE_3_5_HAIKU.getValue()) + .run(context -> { + + AnthropicChatModel chatModel = context.getBean(AnthropicChatModel.class); + + var userMessage = new UserMessage( + "What's the weather like in San Francisco, in Paris, France and in Tokyo, Japan? Return the temperature in Celsius."); + + ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), + AnthropicChatOptions.builder().function("weatherFunction").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + + response = chatModel.call(new Prompt(List.of(userMessage), + AnthropicChatOptions.builder().function("weatherFunction3").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + + }); + } + + @Test + void functionCallWithPortableFunctionCallingOptions() { + + this.contextRunner + .withPropertyValues( + "spring.ai.anthropic.chat.options.model=" + AnthropicApi.ChatModel.CLAUDE_3_5_HAIKU.getValue()) + .run(context -> { + + AnthropicChatModel chatModel = context.getBean(AnthropicChatModel.class); + + var userMessage = new UserMessage( + "What's the weather like in San Francisco, in Paris, France and in Tokyo, Japan? Return the temperature in Celsius."); + + ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), + FunctionCallingOptions.builder().function("weatherFunction").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + }); + } + + @Configuration + static class Config { + + @Bean + @Description("Get the weather in location. Return temperature in 36°F or 36°C format.") + public Function weatherFunction() { + return new MockWeatherService(); + } + + // Relies on the Request's JsonClassDescription annotation to provide the + // function description. + @Bean + public Function weatherFunction3() { + MockWeatherService weatherService = new MockWeatherService(); + return (weatherService::apply); + } + + } + +} diff --git a/auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/anthropic/tool/FunctionCallWithPromptFunctionIT.java b/auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/anthropic/tool/FunctionCallWithPromptFunctionIT.java new file mode 100644 index 00000000000..8121991e93d --- /dev/null +++ b/auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/anthropic/tool/FunctionCallWithPromptFunctionIT.java @@ -0,0 +1,76 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.anthropic.tool; + +import java.util.List; + +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.anthropic.AnthropicChatModel; +import org.springframework.ai.anthropic.AnthropicChatOptions; +import org.springframework.ai.anthropic.api.AnthropicApi; +import org.springframework.ai.autoconfigure.anthropic.AnthropicAutoConfiguration; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.tool.function.FunctionToolCallback; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +@EnabledIfEnvironmentVariable(named = "ANTHROPIC_API_KEY", matches = ".*") +public class FunctionCallWithPromptFunctionIT { + + private final Logger logger = LoggerFactory.getLogger(FunctionCallWithPromptFunctionIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.anthropic.apiKey=" + System.getenv("ANTHROPIC_API_KEY")) + .withConfiguration(AutoConfigurations.of(AnthropicAutoConfiguration.class)); + + @Test + void functionCallTest() { + this.contextRunner + .withPropertyValues( + "spring.ai.anthropic.chat.options.model=" + AnthropicApi.ChatModel.CLAUDE_3_5_HAIKU.getValue()) + .run(context -> { + + AnthropicChatModel chatModel = context.getBean(AnthropicChatModel.class); + + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, in Paris and in Tokyo? Return the temperature in Celsius."); + + var promptOptions = AnthropicChatOptions.builder() + .functionCallbacks( + List.of(FunctionToolCallback.builder("CurrentWeatherService", new MockWeatherService()) + .description("Get the weather in location. Return temperature in 36°F or 36°C format.") + .inputType(MockWeatherService.Request.class) + .build())) + .build(); + + ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), promptOptions)); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/anthropic/tool/MockWeatherService.java b/auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/anthropic/tool/MockWeatherService.java new file mode 100644 index 00000000000..4d39e27f447 --- /dev/null +++ b/auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/anthropic/tool/MockWeatherService.java @@ -0,0 +1,95 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.anthropic.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; + +/** + * Mock 3rd party weather service. + * + * @author Christian Tzolov + */ +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/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/pom.xml b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/pom.xml new file mode 100644 index 00000000000..8a74bb11588 --- /dev/null +++ b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/pom.xml @@ -0,0 +1,93 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../../pom.xml + + spring-ai-azure-openai-spring-boot-autoconfigure + jar + Spring AI Azure OpenAI Auto Configuration + Spring AI Azure OpenAI 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-azure-openai + ${project.parent.version} + true + + + + + + org.springframework.ai + spring-ai-chat-client-spring-boot-autoconfigure + ${project.parent.version} + true + + + + org.springframework.ai + spring-ai-chat-model-spring-boot-autoconfigure + ${project.parent.version} + true + + + + org.springframework.ai + spring-ai-retry-spring-boot-autoconfigure + ${project.parent.version} + true + + + + + org.springframework.boot + spring-boot-starter + true + + + + org.springframework.boot + spring-boot-configuration-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-azure-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/azure/openai/AzureOpenAIClientBuilderCustomizer.java b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/azure/openai/AzureOpenAIClientBuilderCustomizer.java new file mode 100644 index 00000000000..3a5c7624a03 --- /dev/null +++ b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/azure/openai/AzureOpenAIClientBuilderCustomizer.java @@ -0,0 +1,21 @@ +package org.springframework.ai.autoconfigure.azure.openai; + +import com.azure.ai.openai.OpenAIClientBuilder; + +/** + * Callback interface that can be implemented by beans wishing to customize the + * {@link OpenAIClientBuilder} whilst retaining the default auto-configuration. + * + * @author Manuel Andreo Garcia + * @since 1.0.0-M6 + */ +@FunctionalInterface +public interface AzureOpenAIClientBuilderCustomizer { + + /** + * Customize the {@link OpenAIClientBuilder}. + * @param clientBuilder the {@link OpenAIClientBuilder} to customize + */ + void customize(OpenAIClientBuilder clientBuilder); + +} diff --git a/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/azure/openai/AzureOpenAiAudioTranscriptionProperties.java b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/azure/openai/AzureOpenAiAudioTranscriptionProperties.java new file mode 100644 index 00000000000..d7b949616f7 --- /dev/null +++ b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/azure/openai/AzureOpenAiAudioTranscriptionProperties.java @@ -0,0 +1,57 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.azure.openai; + +import org.springframework.ai.azure.openai.AzureOpenAiAudioTranscriptionOptions; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * Configuration properties for Azure OpenAI audio transcription. + * + * @author Piotr Olaszewski + */ +@ConfigurationProperties(AzureOpenAiAudioTranscriptionProperties.CONFIG_PREFIX) +public class AzureOpenAiAudioTranscriptionProperties { + + public static final String CONFIG_PREFIX = "spring.ai.azure.openai.audio.transcription"; + + /** + * Enable AzureOpenAI audio transcription model. + */ + private boolean enabled = true; + + @NestedConfigurationProperty + private AzureOpenAiAudioTranscriptionOptions options = AzureOpenAiAudioTranscriptionOptions.builder().build(); + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public AzureOpenAiAudioTranscriptionOptions getOptions() { + return this.options; + } + + public void setOptions(AzureOpenAiAudioTranscriptionOptions options) { + this.options = options; + } + +} diff --git a/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/azure/openai/AzureOpenAiAutoConfiguration.java b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/azure/openai/AzureOpenAiAutoConfiguration.java new file mode 100644 index 00000000000..ae3b77efcc7 --- /dev/null +++ b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/azure/openai/AzureOpenAiAutoConfiguration.java @@ -0,0 +1,193 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.azure.openai; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import com.azure.ai.openai.OpenAIClientBuilder; +import com.azure.core.credential.AzureKeyCredential; +import com.azure.core.credential.KeyCredential; +import com.azure.core.credential.TokenCredential; +import com.azure.core.util.ClientOptions; +import com.azure.core.util.Header; +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.ai.autoconfigure.chat.model.ToolCallingAutoConfiguration; +import org.springframework.ai.azure.openai.AzureOpenAiAudioTranscriptionModel; +import org.springframework.ai.azure.openai.AzureOpenAiChatModel; +import org.springframework.ai.azure.openai.AzureOpenAiEmbeddingModel; +import org.springframework.ai.azure.openai.AzureOpenAiImageModel; +import org.springframework.ai.chat.observation.ChatModelObservationConvention; +import org.springframework.ai.embedding.observation.EmbeddingModelObservationConvention; +import org.springframework.ai.model.function.DefaultFunctionCallbackResolver; +import org.springframework.ai.model.function.FunctionCallbackResolver; +import org.springframework.ai.model.tool.ToolCallingManager; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +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.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * {@link AutoConfiguration Auto-configuration} for Azure OpenAI. + * + * @author Piotr Olaszewski + * @author Soby Chacko + * @author Manuel Andreo Garcia + * @author Ilayaperumal Gopinathan + */ +@AutoConfiguration(after = { ToolCallingAutoConfiguration.class }) +@ConditionalOnClass({ OpenAIClientBuilder.class, AzureOpenAiChatModel.class }) +@EnableConfigurationProperties({ AzureOpenAiChatProperties.class, AzureOpenAiEmbeddingProperties.class, + AzureOpenAiConnectionProperties.class, AzureOpenAiImageOptionsProperties.class, + AzureOpenAiAudioTranscriptionProperties.class }) +@ImportAutoConfiguration(classes = { ToolCallingAutoConfiguration.class }) +public class AzureOpenAiAutoConfiguration { + + private static final String APPLICATION_ID = "spring-ai"; + + @Bean + @ConditionalOnMissingBean // ({ OpenAIClient.class, TokenCredential.class }) + public OpenAIClientBuilder openAIClientBuilder(AzureOpenAiConnectionProperties connectionProperties, + ObjectProvider customizers) { + + if (StringUtils.hasText(connectionProperties.getApiKey())) { + + Assert.hasText(connectionProperties.getEndpoint(), "Endpoint must not be empty"); + + Map customHeaders = connectionProperties.getCustomHeaders(); + List

headers = customHeaders.entrySet() + .stream() + .map(entry -> new Header(entry.getKey(), entry.getValue())) + .collect(Collectors.toList()); + ClientOptions clientOptions = new ClientOptions().setApplicationId(APPLICATION_ID).setHeaders(headers); + OpenAIClientBuilder clientBuilder = new OpenAIClientBuilder().endpoint(connectionProperties.getEndpoint()) + .credential(new AzureKeyCredential(connectionProperties.getApiKey())) + .clientOptions(clientOptions); + applyOpenAIClientBuilderCustomizers(clientBuilder, customizers); + return clientBuilder; + } + + // Connect to OpenAI (e.g. not the Azure OpenAI). The deploymentName property is + // used as OpenAI model name. + if (StringUtils.hasText(connectionProperties.getOpenAiApiKey())) { + OpenAIClientBuilder clientBuilder = new OpenAIClientBuilder().endpoint("https://api.openai.com/v1") + .credential(new KeyCredential(connectionProperties.getOpenAiApiKey())) + .clientOptions(new ClientOptions().setApplicationId(APPLICATION_ID)); + applyOpenAIClientBuilderCustomizers(clientBuilder, customizers); + return clientBuilder; + } + + throw new IllegalArgumentException("Either API key or OpenAI API key must not be empty"); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnBean(TokenCredential.class) + public OpenAIClientBuilder openAIClientWithTokenCredential(AzureOpenAiConnectionProperties connectionProperties, + TokenCredential tokenCredential, ObjectProvider customizers) { + + Assert.notNull(tokenCredential, "TokenCredential must not be null"); + Assert.hasText(connectionProperties.getEndpoint(), "Endpoint must not be empty"); + + OpenAIClientBuilder clientBuilder = new OpenAIClientBuilder().endpoint(connectionProperties.getEndpoint()) + .credential(tokenCredential) + .clientOptions(new ClientOptions().setApplicationId(APPLICATION_ID)); + applyOpenAIClientBuilderCustomizers(clientBuilder, customizers); + return clientBuilder; + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = AzureOpenAiChatProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + public AzureOpenAiChatModel azureOpenAiChatModel(OpenAIClientBuilder openAIClientBuilder, + AzureOpenAiChatProperties chatProperties, ToolCallingManager toolCallingManager, + ObjectProvider observationRegistry, + ObjectProvider observationConvention) { + + var chatModel = AzureOpenAiChatModel.builder() + .openAIClientBuilder(openAIClientBuilder) + .defaultOptions(chatProperties.getOptions()) + .toolCallingManager(toolCallingManager) + .observationRegistry(observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)) + .build(); + observationConvention.ifAvailable(chatModel::setObservationConvention); + + return chatModel; + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = AzureOpenAiEmbeddingProperties.CONFIG_PREFIX, name = "enabled", + havingValue = "true", matchIfMissing = true) + public AzureOpenAiEmbeddingModel azureOpenAiEmbeddingModel(OpenAIClientBuilder openAIClient, + AzureOpenAiEmbeddingProperties embeddingProperties, ObjectProvider observationRegistry, + ObjectProvider observationConvention) { + + var embeddingModel = new AzureOpenAiEmbeddingModel(openAIClient.buildClient(), + embeddingProperties.getMetadataMode(), embeddingProperties.getOptions(), + observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)); + + observationConvention.ifAvailable(embeddingModel::setObservationConvention); + + return embeddingModel; + + } + + @Bean + @ConditionalOnMissingBean + public FunctionCallbackResolver springAiFunctionManager(ApplicationContext context) { + DefaultFunctionCallbackResolver manager = new DefaultFunctionCallbackResolver(); + manager.setApplicationContext(context); + return manager; + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = AzureOpenAiImageOptionsProperties.CONFIG_PREFIX, name = "enabled", + havingValue = "true", matchIfMissing = true) + public AzureOpenAiImageModel azureOpenAiImageClient(OpenAIClientBuilder openAIClientBuilder, + AzureOpenAiImageOptionsProperties imageProperties) { + + return new AzureOpenAiImageModel(openAIClientBuilder.buildClient(), imageProperties.getOptions()); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = AzureOpenAiAudioTranscriptionProperties.CONFIG_PREFIX, name = "enabled", + havingValue = "true", matchIfMissing = true) + public AzureOpenAiAudioTranscriptionModel azureOpenAiAudioTranscriptionModel(OpenAIClientBuilder openAIClient, + AzureOpenAiAudioTranscriptionProperties audioProperties) { + return new AzureOpenAiAudioTranscriptionModel(openAIClient.buildClient(), audioProperties.getOptions()); + } + + private void applyOpenAIClientBuilderCustomizers(OpenAIClientBuilder clientBuilder, + ObjectProvider customizers) { + customizers.orderedStream().forEach(customizer -> customizer.customize(clientBuilder)); + } + +} diff --git a/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/azure/openai/AzureOpenAiChatProperties.java b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/azure/openai/AzureOpenAiChatProperties.java new file mode 100644 index 00000000000..3323bafeae5 --- /dev/null +++ b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/azure/openai/AzureOpenAiChatProperties.java @@ -0,0 +1,59 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.azure.openai; + +import org.springframework.ai.azure.openai.AzureOpenAiChatOptions; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +@ConfigurationProperties(AzureOpenAiChatProperties.CONFIG_PREFIX) +public class AzureOpenAiChatProperties { + + public static final String CONFIG_PREFIX = "spring.ai.azure.openai.chat"; + + public static final String DEFAULT_DEPLOYMENT_NAME = "gpt-4o"; + + private static final Double DEFAULT_TEMPERATURE = 0.7; + + /** + * Enable Azure OpenAI chat model. + */ + private boolean enabled = true; + + @NestedConfigurationProperty + private AzureOpenAiChatOptions options = AzureOpenAiChatOptions.builder() + .deploymentName(DEFAULT_DEPLOYMENT_NAME) + .temperature(DEFAULT_TEMPERATURE) + .build(); + + public AzureOpenAiChatOptions getOptions() { + return this.options; + } + + public void setOptions(AzureOpenAiChatOptions options) { + this.options = options; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + +} diff --git a/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/azure/openai/AzureOpenAiConnectionProperties.java b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/azure/openai/AzureOpenAiConnectionProperties.java new file mode 100644 index 00000000000..6ede4e8b323 --- /dev/null +++ b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/azure/openai/AzureOpenAiConnectionProperties.java @@ -0,0 +1,81 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.azure.openai; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(AzureOpenAiConnectionProperties.CONFIG_PREFIX) +public class AzureOpenAiConnectionProperties { + + public static final String CONFIG_PREFIX = "spring.ai.azure.openai"; + + /** + * Azure OpenAI API key. From the Azure AI OpenAI `Keys and Endpoint` section under + * `Resource Management`. + */ + private String apiKey; + + /** + * (non Azure) OpenAI API key. Used to authenticate with the OpenAI service, instead + * of Azure OpenAI. This automatically sets the endpoint to https://api.openai.com/v1. + */ + private String openAiApiKey; + + /** + * Azure OpenAI API endpoint. From the Azure AI OpenAI `Keys and Endpoint` section + * under `Resource Management`. + */ + private String endpoint; + + private Map customHeaders = new HashMap<>(); + + public String getEndpoint() { + return this.endpoint; + } + + public void setEndpoint(String endpoint) { + this.endpoint = endpoint; + } + + public String getApiKey() { + return this.apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getOpenAiApiKey() { + return this.openAiApiKey; + } + + public void setOpenAiApiKey(String openAiApiKey) { + this.openAiApiKey = openAiApiKey; + } + + public Map getCustomHeaders() { + return this.customHeaders; + } + + public void setCustomHeaders(Map customHeaders) { + this.customHeaders = customHeaders; + } + +} diff --git a/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/azure/openai/AzureOpenAiEmbeddingProperties.java b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/azure/openai/AzureOpenAiEmbeddingProperties.java new file mode 100644 index 00000000000..f4e9c46799d --- /dev/null +++ b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/azure/openai/AzureOpenAiEmbeddingProperties.java @@ -0,0 +1,68 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.azure.openai; + +import org.springframework.ai.azure.openai.AzureOpenAiEmbeddingOptions; +import org.springframework.ai.document.MetadataMode; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; +import org.springframework.util.Assert; + +@ConfigurationProperties(AzureOpenAiEmbeddingProperties.CONFIG_PREFIX) +public class AzureOpenAiEmbeddingProperties { + + public static final String CONFIG_PREFIX = "spring.ai.azure.openai.embedding"; + + /** + * Enable Azure OpenAI embedding model. + */ + private boolean enabled = true; + + @NestedConfigurationProperty + private AzureOpenAiEmbeddingOptions options = AzureOpenAiEmbeddingOptions.builder() + .deploymentName("text-embedding-ada-002") + .build(); + + private MetadataMode metadataMode = MetadataMode.EMBED; + + public AzureOpenAiEmbeddingOptions getOptions() { + return this.options; + } + + public void setOptions(AzureOpenAiEmbeddingOptions options) { + Assert.notNull(options, "Options must not be null"); + this.options = options; + } + + public MetadataMode getMetadataMode() { + return this.metadataMode; + } + + public void setMetadataMode(MetadataMode metadataMode) { + Assert.notNull(metadataMode, "Metadata mode must not be null"); + this.metadataMode = metadataMode; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + +} diff --git a/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/azure/openai/AzureOpenAiImageOptionsProperties.java b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/azure/openai/AzureOpenAiImageOptionsProperties.java new file mode 100644 index 00000000000..4aea1459f4c --- /dev/null +++ b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/azure/openai/AzureOpenAiImageOptionsProperties.java @@ -0,0 +1,58 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.azure.openai; + +import org.springframework.ai.azure.openai.AzureOpenAiImageOptions; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * Configuration properties for Azure OpenAI image generation options. + * + * @author Benoit Moussaud + * @since 1.0.0 M1 + */ +@ConfigurationProperties(AzureOpenAiImageOptionsProperties.CONFIG_PREFIX) +public class AzureOpenAiImageOptionsProperties { + + public static final String CONFIG_PREFIX = "spring.ai.azure.openai.image"; + + /** + * Enable Azure OpenAI chat client. + */ + private boolean enabled = true; + + @NestedConfigurationProperty + private AzureOpenAiImageOptions options = AzureOpenAiImageOptions.builder().build(); + + public AzureOpenAiImageOptions getOptions() { + return this.options; + } + + public void setOptions(AzureOpenAiImageOptions options) { + this.options = options; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + +} diff --git a/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 00000000000..b547459b8a9 --- /dev/null +++ b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,23 @@ +{ + "groups": [ + { + "name": "spring.ai.azure.openai.chat.options.enhancements", + "type": "com.azure.ai.openai.models.AzureChatEnhancementConfiguration", + "sourceType": "org.springframework.ai.azure.openai.AzureOpenAiChatOptions", + "sourceMethod": "getEnhancements()" + } + ], + "properties": [ + { + "name": "spring.ai.azure.openai.chat.options.enhancements.grounding", + "type": "com.azure.ai.openai.models.AzureChatGroundingEnhancementConfiguration", + "sourceType": "com.azure.ai.openai.models.AzureChatEnhancementConfiguration" + }, + { + "name": "spring.ai.azure.openai.chat.options.enhancements.ocr", + "type": "com.azure.ai.openai.models.AzureChatOCREnhancementConfiguration", + "sourceType": "com.azure.ai.openai.models.AzureChatEnhancementConfiguration" + } + ], + "hints": [] +} diff --git a/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..dc0ed698402 --- /dev/null +++ b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/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.autoconfigure.azure.openai.AzureOpenAiAutoConfiguration diff --git a/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/AzureOpenAiAutoConfigurationIT.java b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/AzureOpenAiAutoConfigurationIT.java new file mode 100644 index 00000000000..73cf13eabf5 --- /dev/null +++ b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/AzureOpenAiAutoConfigurationIT.java @@ -0,0 +1,252 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.azure; + +import java.lang.reflect.Field; +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Collectors; + +import com.azure.ai.openai.OpenAIClient; +import com.azure.ai.openai.OpenAIClientBuilder; +import com.azure.ai.openai.implementation.OpenAIClientImpl; +import com.azure.core.http.HttpHeader; +import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpMethod; +import com.azure.core.http.HttpPipeline; +import com.azure.core.http.HttpRequest; +import com.azure.core.http.HttpResponse; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import org.springframework.ai.autoconfigure.azure.openai.AzureOpenAIClientBuilderCustomizer; + +import reactor.core.publisher.Flux; + +import org.springframework.ai.autoconfigure.azure.openai.AzureOpenAiAutoConfiguration; +import org.springframework.ai.azure.openai.AzureOpenAiAudioTranscriptionModel; +import org.springframework.ai.azure.openai.AzureOpenAiChatModel; +import org.springframework.ai.azure.openai.AzureOpenAiEmbeddingModel; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.chat.prompt.SystemPromptTemplate; +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.util.ReflectionUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Christian Tzolov + * @author Piotr Olaszewski + * @author Soby Chacko + * @author Manuel Andreo Garcia + * @since 0.8.0 + */ +@EnabledIfEnvironmentVariable(named = "AZURE_OPENAI_API_KEY", matches = ".+") +@EnabledIfEnvironmentVariable(named = "AZURE_OPENAI_ENDPOINT", matches = ".+") +class AzureOpenAiAutoConfigurationIT { + + private static String CHAT_MODEL_NAME = "gpt-4o"; + + private static String EMBEDDING_MODEL_NAME = "text-embedding-ada-002"; + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.azure.openai.api-key=" + System.getenv("AZURE_OPENAI_API_KEY"), + "spring.ai.azure.openai.endpoint=" + System.getenv("AZURE_OPENAI_ENDPOINT"), + + "spring.ai.azure.openai.chat.options.deployment-name=" + CHAT_MODEL_NAME, + "spring.ai.azure.openai.chat.options.temperature=0.8", + "spring.ai.azure.openai.chat.options.maxTokens=123", + + "spring.ai.azure.openai.embedding.options.deployment-name=" + EMBEDDING_MODEL_NAME, + "spring.ai.azure.openai.audio.transcription.options.deployment-name=" + System.getenv("AZURE_OPENAI_TRANSCRIPTION_DEPLOYMENT_NAME") + // @formatter:on + ).withConfiguration(AutoConfigurations.of(AzureOpenAiAutoConfiguration.class)); + + private final Message systemMessage = new SystemPromptTemplate(""" + You are a helpful AI assistant. Your name is {name}. + 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}. + """).createMessage(Map.of("name", "Bob", "voice", "pirate")); + + private final UserMessage userMessage = new UserMessage( + "Tell me about 3 famous pirates from the Golden Age of Piracy and why they did."); + + @Test + void chatCompletion() { + this.contextRunner.run(context -> { + AzureOpenAiChatModel chatModel = context.getBean(AzureOpenAiChatModel.class); + ChatResponse response = chatModel.call(new Prompt(List.of(this.userMessage, this.systemMessage))); + assertThat(response.getResult().getOutput().getText()).contains("Blackbeard"); + }); + } + + @Test + void httpRequestContainsUserAgentAndCustomHeaders() { + this.contextRunner + .withPropertyValues("spring.ai.azure.openai.custom-headers.foo=bar", + "spring.ai.azure.openai.custom-headers.fizz=buzz") + .run(context -> { + OpenAIClientBuilder openAIClientBuilder = context.getBean(OpenAIClientBuilder.class); + OpenAIClient openAIClient = openAIClientBuilder.buildClient(); + Field serviceClientField = ReflectionUtils.findField(OpenAIClient.class, "serviceClient"); + assertThat(serviceClientField).isNotNull(); + ReflectionUtils.makeAccessible(serviceClientField); + OpenAIClientImpl oaci = (OpenAIClientImpl) ReflectionUtils.getField(serviceClientField, openAIClient); + assertThat(oaci).isNotNull(); + HttpPipeline httpPipeline = oaci.getHttpPipeline(); + HttpResponse httpResponse = httpPipeline + .send(new HttpRequest(HttpMethod.POST, new URI(System.getenv("AZURE_OPENAI_ENDPOINT")).toURL())) + .block(); + assertThat(httpResponse).isNotNull(); + HttpHeader httpHeader = httpResponse.getRequest().getHeaders().get(HttpHeaderName.USER_AGENT); + assertThat(httpHeader.getValue().startsWith("spring-ai azsdk-java-azure-ai-openai/")).isTrue(); + HttpHeader customHeader1 = httpResponse.getRequest().getHeaders().get("foo"); + assertThat(customHeader1.getValue()).isEqualTo("bar"); + HttpHeader customHeader2 = httpResponse.getRequest().getHeaders().get("fizz"); + assertThat(customHeader2.getValue()).isEqualTo("buzz"); + }); + } + + @Test + void chatCompletionStreaming() { + this.contextRunner.run(context -> { + + AzureOpenAiChatModel chatModel = context.getBean(AzureOpenAiChatModel.class); + + Flux response = chatModel.stream(new Prompt(List.of(this.userMessage, this.systemMessage))); + + List responses = response.collectList().block(); + assertThat(responses.size()).isGreaterThan(10); + + String stitchedResponseContent = responses.stream() + .map(ChatResponse::getResults) + .flatMap(List::stream) + .map(Generation::getOutput) + .map(AssistantMessage::getText) + .collect(Collectors.joining()); + + assertThat(stitchedResponseContent).contains("Blackbeard"); + }); + } + + @Test + void embedding() { + this.contextRunner.run(context -> { + AzureOpenAiEmbeddingModel embeddingModel = context.getBean(AzureOpenAiEmbeddingModel.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); + }); + } + + @Test + @EnabledIfEnvironmentVariable(named = "AZURE_OPENAI_TRANSCRIPTION_DEPLOYMENT_NAME", matches = ".+") + void transcribe() { + this.contextRunner.run(context -> { + AzureOpenAiAudioTranscriptionModel transcriptionModel = context + .getBean(AzureOpenAiAudioTranscriptionModel.class); + Resource audioFile = new ClassPathResource("/speech/jfk.flac"); + String response = transcriptionModel.call(audioFile); + assertThat(response).isEqualTo( + "And so my fellow Americans, ask not what your country can do for you, ask what you can do for your country."); + }); + } + + @Test + void chatActivation() { + + // Disable the chat auto-configuration. + this.contextRunner.withPropertyValues("spring.ai.azure.openai.chat.enabled=false") + .run(context -> assertThat(context.getBeansOfType(AzureOpenAiChatModel.class)).isEmpty()); + + // The chat auto-configuration is enabled by default. + this.contextRunner.run(context -> assertThat(context.getBeansOfType(AzureOpenAiChatModel.class)).isNotEmpty()); + + // Explicitly enable the chat auto-configuration. + this.contextRunner.withPropertyValues("spring.ai.azure.openai.chat.enabled=true") + .run(context -> assertThat(context.getBeansOfType(AzureOpenAiChatModel.class)).isNotEmpty()); + } + + @Test + void embeddingActivation() { + + // Disable the embedding auto-configuration. + this.contextRunner.withPropertyValues("spring.ai.azure.openai.embedding.enabled=false") + .run(context -> assertThat(context.getBeansOfType(AzureOpenAiEmbeddingModel.class)).isEmpty()); + + // The embedding auto-configuration is enabled by default. + this.contextRunner + .run(context -> assertThat(context.getBeansOfType(AzureOpenAiEmbeddingModel.class)).isNotEmpty()); + + // Explicitly enable the embedding auto-configuration. + this.contextRunner.withPropertyValues("spring.ai.azure.openai.embedding.enabled=true") + .run(context -> assertThat(context.getBeansOfType(AzureOpenAiEmbeddingModel.class)).isNotEmpty()); + } + + @Test + void audioTranscriptionActivation() { + + // Disable the transcription auto-configuration. + this.contextRunner.withPropertyValues("spring.ai.azure.openai.audio.transcription.enabled=false") + .run(context -> assertThat(context.getBeansOfType(AzureOpenAiAudioTranscriptionModel.class)).isEmpty()); + + // The transcription auto-configuration is enabled by default. + this.contextRunner + .run(context -> assertThat(context.getBeansOfType(AzureOpenAiAudioTranscriptionModel.class)).isNotEmpty()); + + // Explicitly enable the transcription auto-configuration. + this.contextRunner.withPropertyValues("spring.ai.azure.openai.audio.transcription.enabled=true") + .run(context -> assertThat(context.getBeansOfType(AzureOpenAiAudioTranscriptionModel.class)).isNotEmpty()); + } + + @Test + void openAIClientBuilderCustomizer() { + AtomicBoolean firstCustomizationApplied = new AtomicBoolean(false); + AtomicBoolean secondCustomizationApplied = new AtomicBoolean(false); + this.contextRunner + .withBean("first", AzureOpenAIClientBuilderCustomizer.class, + () -> clientBuilder -> firstCustomizationApplied.set(true)) + .withBean("second", AzureOpenAIClientBuilderCustomizer.class, + () -> clientBuilder -> secondCustomizationApplied.set(true)) + .run(context -> { + context.getBean(OpenAIClientBuilder.class); + assertThat(firstCustomizationApplied.get()).isTrue(); + assertThat(secondCustomizationApplied.get()).isTrue(); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/AzureOpenAiAutoConfigurationPropertyTests.java b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/AzureOpenAiAutoConfigurationPropertyTests.java new file mode 100644 index 00000000000..e83c75e2327 --- /dev/null +++ b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/AzureOpenAiAutoConfigurationPropertyTests.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.autoconfigure.azure; + +import org.junit.jupiter.api.Test; + +import org.springframework.ai.autoconfigure.azure.openai.AzureOpenAiAutoConfiguration; +import org.springframework.ai.autoconfigure.azure.openai.AzureOpenAiChatProperties; +import org.springframework.ai.autoconfigure.azure.openai.AzureOpenAiConnectionProperties; +import org.springframework.ai.autoconfigure.azure.openai.AzureOpenAiEmbeddingProperties; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Christian Tzolov + * @since 0.8.0 + */ +public class AzureOpenAiAutoConfigurationPropertyTests { + + @Test + public void embeddingPropertiesTest() { + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.azure.openai.api-key=TEST_API_KEY", + "spring.ai.azure.openai.endpoint=TEST_ENDPOINT", + "spring.ai.azure.openai.embedding.options.deployment-name=MODEL_XYZ") + .withConfiguration(AutoConfigurations.of(AzureOpenAiAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(AzureOpenAiEmbeddingProperties.class); + var connectionProperties = context.getBean(AzureOpenAiConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("TEST_API_KEY"); + assertThat(connectionProperties.getEndpoint()).isEqualTo("TEST_ENDPOINT"); + + assertThat(chatProperties.getOptions().getDeploymentName()).isEqualTo("MODEL_XYZ"); + }); + } + + @Test + public void chatPropertiesTest() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.azure.openai.api-key=API_KEY", + "spring.ai.azure.openai.endpoint=ENDPOINT", + + "spring.ai.azure.openai.chat.options.deployment-name=MODEL_XYZ", + "spring.ai.azure.openai.chat.options.frequencyPenalty=-1.5", + "spring.ai.azure.openai.chat.options.logitBias.myTokenId=-5", + "spring.ai.azure.openai.chat.options.maxTokens=123", + "spring.ai.azure.openai.chat.options.n=10", + "spring.ai.azure.openai.chat.options.presencePenalty=0", + "spring.ai.azure.openai.chat.options.stop=boza,koza", + "spring.ai.azure.openai.chat.options.temperature=0.55", + "spring.ai.azure.openai.chat.options.topP=0.56", + "spring.ai.azure.openai.chat.options.user=userXYZ" + ) + // @formatter:on + .withConfiguration(AutoConfigurations.of(AzureOpenAiAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(AzureOpenAiChatProperties.class); + var connectionProperties = context.getBean(AzureOpenAiConnectionProperties.class); + var embeddingProperties = context.getBean(AzureOpenAiEmbeddingProperties.class); + + assertThat(connectionProperties.getEndpoint()).isEqualTo("ENDPOINT"); + assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY"); + + assertThat(embeddingProperties.getOptions().getDeploymentName()).isEqualTo("text-embedding-ada-002"); + + assertThat(chatProperties.getOptions().getDeploymentName()).isEqualTo("MODEL_XYZ"); + assertThat(chatProperties.getOptions().getFrequencyPenalty()).isEqualTo(-1.5); + assertThat(chatProperties.getOptions().getLogitBias().get("myTokenId")).isEqualTo(-5); + assertThat(chatProperties.getOptions().getMaxTokens()).isEqualTo(123); + assertThat(chatProperties.getOptions().getN()).isEqualTo(10); + assertThat(chatProperties.getOptions().getPresencePenalty()).isEqualTo(0); + assertThat(chatProperties.getOptions().getStop()).contains("boza", "koza"); + assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55); + assertThat(chatProperties.getOptions().getTopP()).isEqualTo(0.56); + + assertThat(chatProperties.getOptions().getUser()).isEqualTo("userXYZ"); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/AzureOpenAiDirectOpenAiAutoConfigurationIT.java b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/AzureOpenAiDirectOpenAiAutoConfigurationIT.java new file mode 100644 index 00000000000..88de524d13a --- /dev/null +++ b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/AzureOpenAiDirectOpenAiAutoConfigurationIT.java @@ -0,0 +1,123 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.azure; + +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 reactor.core.publisher.Flux; + +import org.springframework.ai.autoconfigure.azure.openai.AzureOpenAiAutoConfiguration; +import org.springframework.ai.azure.openai.AzureOpenAiChatModel; +import org.springframework.ai.azure.openai.AzureOpenAiEmbeddingModel; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.chat.prompt.SystemPromptTemplate; +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Christian Tzolov + * @since 1.0.0 + */ +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".*") +public class AzureOpenAiDirectOpenAiAutoConfigurationIT { + + private static String CHAT_MODEL_NAME = "gpt-4o"; + + private static String EMBEDDING_MODEL_NAME = "text-embedding-ada-002"; + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.azure.openai.openai-api-key=" + System.getenv("OPENAI_API_KEY"), + + "spring.ai.azure.openai.chat.options.deployment-name=" + CHAT_MODEL_NAME, + "spring.ai.azure.openai.chat.options.temperature=0.8", + "spring.ai.azure.openai.chat.options.maxTokens=123", + "spring.ai.azure.openai.embedding.options.deployment-name=" + EMBEDDING_MODEL_NAME + // @formatter:on + ).withConfiguration(AutoConfigurations.of(AzureOpenAiAutoConfiguration.class)); + + private final Message systemMessage = new SystemPromptTemplate(""" + You are a helpful AI assistant. Your name is {name}. + 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}. + """).createMessage(Map.of("name", "Bob", "voice", "pirate")); + + private final UserMessage userMessage = new UserMessage( + "Tell me about 3 famous pirates from the Golden Age of Piracy and why they did."); + + @Test + public void chatCompletion() { + this.contextRunner.run(context -> { + AzureOpenAiChatModel chatModel = context.getBean(AzureOpenAiChatModel.class); + ChatResponse response = chatModel.call(new Prompt(List.of(this.userMessage, this.systemMessage))); + assertThat(response.getResult().getOutput().getText()).contains("Blackbeard"); + }); + } + + @Test + public void chatCompletionStreaming() { + this.contextRunner.run(context -> { + + AzureOpenAiChatModel chatModel = context.getBean(AzureOpenAiChatModel.class); + + Flux response = chatModel.stream(new Prompt(List.of(this.userMessage, this.systemMessage))); + + List responses = response.collectList().block(); + assertThat(responses.size()).isGreaterThan(10); + + String stitchedResponseContent = responses.stream() + .map(ChatResponse::getResults) + .flatMap(List::stream) + .map(Generation::getOutput) + .map(AssistantMessage::getText) + .collect(Collectors.joining()); + + assertThat(stitchedResponseContent).contains("Blackbeard"); + }); + } + + @Test + void embedding() { + this.contextRunner.run(context -> { + AzureOpenAiEmbeddingModel embeddingModel = context.getBean(AzureOpenAiEmbeddingModel.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-azure-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/tool/DeploymentNameUtil.java b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/tool/DeploymentNameUtil.java new file mode 100644 index 00000000000..435fc9235bb --- /dev/null +++ b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/tool/DeploymentNameUtil.java @@ -0,0 +1,37 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.azure.tool; + +import org.springframework.util.StringUtils; + +public final class DeploymentNameUtil { + + private DeploymentNameUtil() { + + } + + public static String getDeploymentName() { + String deploymentName = System.getenv("AZURE_OPENAI_DEPLOYMENT_NAME"); + if (StringUtils.hasText(deploymentName)) { + return deploymentName; + } + else { + return "gpt-4o"; + } + } + +} diff --git a/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/tool/FunctionCallWithFunctionBeanIT.java b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/tool/FunctionCallWithFunctionBeanIT.java new file mode 100644 index 00000000000..e3b29e75cda --- /dev/null +++ b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/tool/FunctionCallWithFunctionBeanIT.java @@ -0,0 +1,127 @@ +/* + * 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.autoconfigure.azure.tool; + +import java.util.List; +import java.util.function.Function; + +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.autoconfigure.azure.openai.AzureOpenAiAutoConfiguration; +import org.springframework.ai.azure.openai.AzureOpenAiChatModel; +import org.springframework.ai.azure.openai.AzureOpenAiChatOptions; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.model.tool.ToolCallingChatOptions; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Description; + +import static org.assertj.core.api.Assertions.assertThat; + +@EnabledIfEnvironmentVariable(named = "AZURE_OPENAI_API_KEY", matches = ".+") +@EnabledIfEnvironmentVariable(named = "AZURE_OPENAI_ENDPOINT", matches = ".+") +class FunctionCallWithFunctionBeanIT { + + private final Logger logger = LoggerFactory.getLogger(FunctionCallWithFunctionBeanIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.azure.openai.api-key=" + System.getenv("AZURE_OPENAI_API_KEY"), + "spring.ai.azure.openai.endpoint=" + System.getenv("AZURE_OPENAI_ENDPOINT")) + // @formatter:onn + .withConfiguration(AutoConfigurations.of(AzureOpenAiAutoConfiguration.class)) + .withUserConfiguration(Config.class); + + @Test + void functionCallTest() { + this.contextRunner + .withPropertyValues("spring.ai.azure.openai.chat.options..deployment-name=" + + org.springframework.ai.autoconfigure.azure.tool.DeploymentNameUtil.getDeploymentName()) + .run(context -> { + + ChatModel chatModel = context.getBean(AzureOpenAiChatModel.class); + + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Paris and in Tokyo? Use Multi-turn function calling."); + + ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), + AzureOpenAiChatOptions.builder().function("weatherFunction").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + + response = chatModel.call(new Prompt(List.of(userMessage), + AzureOpenAiChatOptions.builder().function("weatherFunction3").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + + }); + } + + @Test + void functionCallWithPortableFunctionCallingOptions() { + this.contextRunner + .withPropertyValues("spring.ai.azure.openai.chat.options..deployment-name=" + + org.springframework.ai.autoconfigure.azure.tool.DeploymentNameUtil.getDeploymentName()) + .run(context -> { + + ChatModel chatModel = context.getBean(AzureOpenAiChatModel.class); + + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Paris and in Tokyo? Use Multi-turn function calling."); + + ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), + ToolCallingChatOptions.builder().toolNames("weatherFunction").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + + }); + } + + @Configuration + static class Config { + + @Bean + @Description("Get the weather in location") + public Function weatherFunction() { + return new MockWeatherService(); + } + + // Relies on the Request's JsonClassDescription annotation to provide the + // function description. + @Bean + public Function weatherFunction3() { + MockWeatherService weatherService = new MockWeatherService(); + return (weatherService::apply); + } + + } + +} diff --git a/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/tool/FunctionCallWithFunctionWrapperIT.java b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/tool/FunctionCallWithFunctionWrapperIT.java new file mode 100644 index 00000000000..7df9cc89d80 --- /dev/null +++ b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/tool/FunctionCallWithFunctionWrapperIT.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.autoconfigure.azure.tool; + +import java.util.List; + +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.autoconfigure.azure.openai.AzureOpenAiAutoConfiguration; +import org.springframework.ai.azure.openai.AzureOpenAiChatModel; +import org.springframework.ai.azure.openai.AzureOpenAiChatOptions; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.function.FunctionToolCallback; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +@EnabledIfEnvironmentVariable(named = "AZURE_OPENAI_API_KEY", matches = ".+") +@EnabledIfEnvironmentVariable(named = "AZURE_OPENAI_ENDPOINT", matches = ".+") +public class FunctionCallWithFunctionWrapperIT { + + private final Logger logger = LoggerFactory.getLogger(FunctionCallWithFunctionWrapperIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.azure.openai.api-key=" + System.getenv("AZURE_OPENAI_API_KEY"), + "spring.ai.azure.openai.endpoint=" + System.getenv("AZURE_OPENAI_ENDPOINT")) + // @formatter:onn + .withConfiguration(AutoConfigurations.of(AzureOpenAiAutoConfiguration.class)) + .withUserConfiguration(Config.class); + + @Test + void functionCallTest() { + this.contextRunner + .withPropertyValues("spring.ai.azure.openai.chat.options.deployment-name=" + + org.springframework.ai.autoconfigure.azure.tool.DeploymentNameUtil.getDeploymentName()) + .run(context -> { + + AzureOpenAiChatModel chatModel = context.getBean(AzureOpenAiChatModel.class); + + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Paris and in Tokyo?"); + + ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), + AzureOpenAiChatOptions.builder().function("WeatherInfo").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).containsAnyOf("30", "10", "15"); + + }); + } + + @Configuration + static class Config { + + @Bean + public ToolCallback weatherFunctionInfo() { + + return FunctionToolCallback.builder("WeatherInfo", new MockWeatherService()) + .description("Get the current weather in a given location") + .inputType(MockWeatherService.Request.class) + .build(); + } + + } + +} diff --git a/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/tool/FunctionCallWithPromptFunctionIT.java b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/tool/FunctionCallWithPromptFunctionIT.java new file mode 100644 index 00000000000..8a50160dc77 --- /dev/null +++ b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/tool/FunctionCallWithPromptFunctionIT.java @@ -0,0 +1,79 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.azure.tool; + +import java.util.List; + +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.autoconfigure.azure.openai.AzureOpenAiAutoConfiguration; +import org.springframework.ai.azure.openai.AzureOpenAiChatModel; +import org.springframework.ai.azure.openai.AzureOpenAiChatOptions; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.tool.function.FunctionToolCallback; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +@EnabledIfEnvironmentVariable(named = "AZURE_OPENAI_API_KEY", matches = ".+") +@EnabledIfEnvironmentVariable(named = "AZURE_OPENAI_ENDPOINT", matches = ".+") +public class FunctionCallWithPromptFunctionIT { + + private final Logger logger = LoggerFactory.getLogger(FunctionCallWithPromptFunctionIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.azure.openai.api-key=" + System.getenv("AZURE_OPENAI_API_KEY"), + "spring.ai.azure.openai.endpoint=" + System.getenv("AZURE_OPENAI_ENDPOINT")) + // @formatter:onn + .withConfiguration(AutoConfigurations.of(AzureOpenAiAutoConfiguration.class)); + + @Test + void functionCallTest() { + this.contextRunner + .withPropertyValues("spring.ai.azure.openai.chat.options.deployment-name=" + + org.springframework.ai.autoconfigure.azure.tool.DeploymentNameUtil.getDeploymentName()) + .run(context -> { + + AzureOpenAiChatModel chatModel = context.getBean(AzureOpenAiChatModel.class); + + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, in Paris and in Tokyo? Use Multi-turn function calling."); + + var promptOptions = AzureOpenAiChatOptions.builder() + .functionCallbacks( + List.of(FunctionToolCallback.builder("CurrentWeatherService", new MockWeatherService()) + .description("Get the weather in location") + .inputType(MockWeatherService.Request.class) + .build())) + .build(); + + ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), promptOptions)); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/tool/MockWeatherService.java b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/tool/MockWeatherService.java new file mode 100644 index 00000000000..a6d2658ea0d --- /dev/null +++ b/auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/azure/tool/MockWeatherService.java @@ -0,0 +1,96 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.azure.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; + +/** + * Mock 3rd party weather service. + * + * @author Christian Tzolov + */ +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/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/pom.xml b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/pom.xml new file mode 100644 index 00000000000..87da575efea --- /dev/null +++ b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/pom.xml @@ -0,0 +1,100 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../../pom.xml + + spring-ai-bedrock-spring-boot-autoconfigure + jar + Spring AI Bedrock Auto Configuration + Spring AI Bedrock 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-bedrock + ${project.parent.version} + true + + + + org.springframework.ai + spring-ai-bedrock-converse + ${project.parent.version} + true + + + + + + org.springframework.ai + spring-ai-chat-client-spring-boot-autoconfigure + ${project.parent.version} + true + + + + org.springframework.ai + spring-ai-chat-model-spring-boot-autoconfigure + ${project.parent.version} + true + + + + org.springframework.ai + spring-ai-retry-spring-boot-autoconfigure + ${project.parent.version} + true + + + + + org.springframework.boot + spring-boot-starter + true + + + + org.springframework.boot + spring-boot-configuration-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-bedrock-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/BedrockAwsConnectionConfiguration.java b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/BedrockAwsConnectionConfiguration.java new file mode 100644 index 00000000000..b083597e6ce --- /dev/null +++ b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/BedrockAwsConnectionConfiguration.java @@ -0,0 +1,93 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.bedrock; + +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.regions.providers.AwsRegionProvider; +import software.amazon.awssdk.regions.providers.DefaultAwsRegionProviderChain; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.StringUtils; + +/** + * {@link Configuration} for AWS connection. + * + * @author Christian Tzolov + * @author Wei Jiang + */ +@Configuration +@EnableConfigurationProperties({ BedrockAwsConnectionProperties.class }) +public class BedrockAwsConnectionConfiguration { + + @Bean + @ConditionalOnMissingBean + public AwsCredentialsProvider credentialsProvider(BedrockAwsConnectionProperties properties) { + + if (StringUtils.hasText(properties.getAccessKey()) && StringUtils.hasText(properties.getSecretKey())) { + + if (StringUtils.hasText(properties.getSessionToken())) { + return StaticCredentialsProvider.create(AwsSessionCredentials.create(properties.getAccessKey(), + properties.getSecretKey(), properties.getSessionToken())); + } + + return StaticCredentialsProvider + .create(AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey())); + } + + return DefaultCredentialsProvider.create(); + } + + @Bean + @ConditionalOnMissingBean + public AwsRegionProvider regionProvider(BedrockAwsConnectionProperties properties) { + + if (StringUtils.hasText(properties.getRegion())) { + return new StaticRegionProvider(properties.getRegion()); + } + + return DefaultAwsRegionProviderChain.builder().build(); + } + + static class StaticRegionProvider implements AwsRegionProvider { + + private final Region region; + + StaticRegionProvider(String region) { + try { + this.region = Region.of(region); + } + catch (IllegalArgumentException e) { + throw new IllegalArgumentException("The region '" + region + "' is not a valid region!", e); + } + } + + @Override + public Region getRegion() { + return this.region; + } + + } + +} diff --git a/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/BedrockAwsConnectionProperties.java b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/BedrockAwsConnectionProperties.java new file mode 100644 index 00000000000..14c6be535fb --- /dev/null +++ b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/BedrockAwsConnectionProperties.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.autoconfigure.bedrock; + +import java.time.Duration; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration properties for Bedrock AWS connection. + * + * @author Christian Tzolov + * @since 0.8.0 + */ +@ConfigurationProperties(BedrockAwsConnectionProperties.CONFIG_PREFIX) +public class BedrockAwsConnectionProperties { + + public static final String CONFIG_PREFIX = "spring.ai.bedrock.aws"; + + /** + * AWS region to use. Defaults to us-east-1. + */ + private String region = "us-east-1"; + + /** + * AWS access key. + */ + private String accessKey; + + /** + * AWS secret key. + */ + private String secretKey; + + /** + * AWS session token. (optional) When provided the AwsSessionCredentials are used. + * Otherwise the AwsBasicCredentials are used. + */ + private String sessionToken; + + /** + * Set model timeout, Defaults 5 min. + */ + private Duration timeout = Duration.ofMinutes(5L); + + public String getRegion() { + return this.region; + } + + public void setRegion(String awsRegion) { + this.region = awsRegion; + } + + public String getAccessKey() { + return this.accessKey; + } + + public void setAccessKey(String accessKey) { + this.accessKey = accessKey; + } + + public String getSecretKey() { + return this.secretKey; + } + + public void setSecretKey(String secretKey) { + this.secretKey = secretKey; + } + + public Duration getTimeout() { + return this.timeout; + } + + public void setTimeout(Duration timeout) { + this.timeout = timeout; + } + + public String getSessionToken() { + return this.sessionToken; + } + + public void setSessionToken(String sessionToken) { + this.sessionToken = sessionToken; + } + +} diff --git a/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/cohere/BedrockCohereEmbeddingAutoConfiguration.java b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/cohere/BedrockCohereEmbeddingAutoConfiguration.java new file mode 100644 index 00000000000..86ba3f76b3d --- /dev/null +++ b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/cohere/BedrockCohereEmbeddingAutoConfiguration.java @@ -0,0 +1,69 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.bedrock.cohere; + +import com.fasterxml.jackson.databind.ObjectMapper; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.regions.providers.AwsRegionProvider; + +import org.springframework.ai.autoconfigure.bedrock.BedrockAwsConnectionConfiguration; +import org.springframework.ai.autoconfigure.bedrock.BedrockAwsConnectionProperties; +import org.springframework.ai.bedrock.cohere.BedrockCohereEmbeddingModel; +import org.springframework.ai.bedrock.cohere.api.CohereEmbeddingBedrockApi; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +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.context.annotation.Bean; +import org.springframework.context.annotation.Import; + +/** + * {@link AutoConfiguration Auto-configuration} for Bedrock Cohere Embedding Model. + * + * @author Christian Tzolov + * @author Wei Jiang + * @since 0.8.0 + */ +@AutoConfiguration +@ConditionalOnClass(CohereEmbeddingBedrockApi.class) +@EnableConfigurationProperties({ BedrockCohereEmbeddingProperties.class, BedrockAwsConnectionProperties.class }) +@ConditionalOnProperty(prefix = BedrockCohereEmbeddingProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true") +@Import(BedrockAwsConnectionConfiguration.class) +public class BedrockCohereEmbeddingAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + @ConditionalOnBean({ AwsCredentialsProvider.class, AwsRegionProvider.class }) + public CohereEmbeddingBedrockApi cohereEmbeddingApi(AwsCredentialsProvider credentialsProvider, + AwsRegionProvider regionProvider, BedrockCohereEmbeddingProperties properties, + BedrockAwsConnectionProperties awsProperties, ObjectMapper objectMapper) { + return new CohereEmbeddingBedrockApi(properties.getModel(), credentialsProvider, regionProvider.getRegion(), + objectMapper, awsProperties.getTimeout()); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnBean(CohereEmbeddingBedrockApi.class) + public BedrockCohereEmbeddingModel cohereEmbeddingModel(CohereEmbeddingBedrockApi cohereEmbeddingApi, + BedrockCohereEmbeddingProperties properties) { + + return new BedrockCohereEmbeddingModel(cohereEmbeddingApi, properties.getOptions()); + } + +} diff --git a/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/cohere/BedrockCohereEmbeddingProperties.java b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/cohere/BedrockCohereEmbeddingProperties.java new file mode 100644 index 00000000000..f6f6b3a957b --- /dev/null +++ b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/cohere/BedrockCohereEmbeddingProperties.java @@ -0,0 +1,78 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.bedrock.cohere; + +import org.springframework.ai.bedrock.cohere.BedrockCohereEmbeddingOptions; +import org.springframework.ai.bedrock.cohere.api.CohereEmbeddingBedrockApi.CohereEmbeddingModel; +import org.springframework.ai.bedrock.cohere.api.CohereEmbeddingBedrockApi.CohereEmbeddingRequest; +import org.springframework.ai.bedrock.cohere.api.CohereEmbeddingBedrockApi.CohereEmbeddingRequest.InputType; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * Bedrock Cohere Embedding autoconfiguration properties. + * + * @author Christian Tzolov + * @since 0.8.0 + */ +@ConfigurationProperties(BedrockCohereEmbeddingProperties.CONFIG_PREFIX) +public class BedrockCohereEmbeddingProperties { + + public static final String CONFIG_PREFIX = "spring.ai.bedrock.cohere.embedding"; + + /** + * Enable Bedrock Cohere Embedding Model. False by default. + */ + private boolean enabled = false; + + /** + * Bedrock Cohere Embedding generative name. Defaults to + * 'cohere.embed-multilingual-v3'. + */ + private String model = CohereEmbeddingModel.COHERE_EMBED_MULTILINGUAL_V3.id(); + + @NestedConfigurationProperty + private BedrockCohereEmbeddingOptions options = BedrockCohereEmbeddingOptions.builder() + .inputType(InputType.SEARCH_DOCUMENT) + .truncate(CohereEmbeddingRequest.Truncate.NONE) + .build(); + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getModel() { + return this.model; + } + + public void setModel(String model) { + this.model = model; + } + + public BedrockCohereEmbeddingOptions getOptions() { + return this.options; + } + + public void setOptions(BedrockCohereEmbeddingOptions options) { + this.options = options; + } + +} diff --git a/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/converse/BedrockConverseProxyChatAutoConfiguration.java b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/converse/BedrockConverseProxyChatAutoConfiguration.java new file mode 100644 index 00000000000..e732d106286 --- /dev/null +++ b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/converse/BedrockConverseProxyChatAutoConfiguration.java @@ -0,0 +1,97 @@ +/* + * Copyright 2024-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.bedrock.converse; + +import io.micrometer.observation.ObservationRegistry; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.regions.providers.AwsRegionProvider; +import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeAsyncClient; +import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient; + +import org.springframework.ai.autoconfigure.bedrock.BedrockAwsConnectionConfiguration; +import org.springframework.ai.autoconfigure.bedrock.BedrockAwsConnectionProperties; +import org.springframework.ai.autoconfigure.chat.model.ToolCallingAutoConfiguration; +import org.springframework.ai.bedrock.converse.BedrockProxyChatModel; +import org.springframework.ai.chat.observation.ChatModelObservationConvention; +import org.springframework.ai.model.function.DefaultFunctionCallbackResolver; +import org.springframework.ai.model.function.FunctionCallbackResolver; +import org.springframework.ai.model.tool.ToolCallingManager; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +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.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; + +/** + * {@link AutoConfiguration Auto-configuration} for Bedrock Converse Proxy Chat Client. + * + * Leverages the Spring Cloud AWS to resolve the {@link AwsCredentialsProvider}. + * + * @author Christian Tzolov + * @author Wei Jiang + */ +@AutoConfiguration(after = { ToolCallingAutoConfiguration.class }) +@EnableConfigurationProperties({ BedrockConverseProxyChatProperties.class, BedrockAwsConnectionConfiguration.class }) +@ConditionalOnClass({ BedrockProxyChatModel.class, BedrockRuntimeClient.class, BedrockRuntimeAsyncClient.class }) +@ConditionalOnProperty(prefix = BedrockConverseProxyChatProperties.CONFIG_PREFIX, name = "enabled", + havingValue = "true", matchIfMissing = true) +@Import(BedrockAwsConnectionConfiguration.class) +@ImportAutoConfiguration({ ToolCallingAutoConfiguration.class }) +public class BedrockConverseProxyChatAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + @ConditionalOnBean({ AwsCredentialsProvider.class, AwsRegionProvider.class }) + public BedrockProxyChatModel bedrockProxyChatModel(AwsCredentialsProvider credentialsProvider, + AwsRegionProvider regionProvider, BedrockAwsConnectionProperties connectionProperties, + BedrockConverseProxyChatProperties chatProperties, ToolCallingManager toolCallingManager, + ObjectProvider observationRegistry, + ObjectProvider observationConvention, + ObjectProvider bedrockRuntimeClient, + ObjectProvider bedrockRuntimeAsyncClient) { + + var chatModel = BedrockProxyChatModel.builder() + .credentialsProvider(credentialsProvider) + .region(regionProvider.getRegion()) + .timeout(connectionProperties.getTimeout()) + .defaultOptions(chatProperties.getOptions()) + .observationRegistry(observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)) + .toolCallingManager(toolCallingManager) + .bedrockRuntimeClient(bedrockRuntimeClient.getIfAvailable()) + .bedrockRuntimeAsyncClient(bedrockRuntimeAsyncClient.getIfAvailable()) + .build(); + + observationConvention.ifAvailable(chatModel::setObservationConvention); + + return chatModel; + } + + @Bean + @ConditionalOnMissingBean + public FunctionCallbackResolver springAiFunctionManager(ApplicationContext context) { + DefaultFunctionCallbackResolver manager = new DefaultFunctionCallbackResolver(); + manager.setApplicationContext(context); + return manager; + } + +} diff --git a/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/converse/BedrockConverseProxyChatProperties.java b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/converse/BedrockConverseProxyChatProperties.java new file mode 100644 index 00000000000..128eaf71b02 --- /dev/null +++ b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/converse/BedrockConverseProxyChatProperties.java @@ -0,0 +1,64 @@ +/* + * Copyright 2024-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.bedrock.converse; + +import org.springframework.ai.model.tool.ToolCallingChatOptions; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; +import org.springframework.util.Assert; + +/** + * Configuration properties for Bedrock Converse. + * + * @author Christian Tzolov + * @since 1.0.0 + */ +@ConfigurationProperties(BedrockConverseProxyChatProperties.CONFIG_PREFIX) +public class BedrockConverseProxyChatProperties { + + public static final String CONFIG_PREFIX = "spring.ai.bedrock.converse.chat"; + + /** + * Enable Bedrock Converse chat model. + */ + private boolean enabled = true; + + @NestedConfigurationProperty + private ToolCallingChatOptions options = ToolCallingChatOptions.builder() + .temperature(0.7) + .maxTokens(300) + .topK(10) + .build(); + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public ToolCallingChatOptions getOptions() { + return this.options; + } + + public void setOptions(ToolCallingChatOptions options) { + Assert.notNull(options, "FunctionCallingOptions must not be null"); + this.options = options; + } + +} diff --git a/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/titan/BedrockTitanEmbeddingAutoConfiguration.java b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/titan/BedrockTitanEmbeddingAutoConfiguration.java new file mode 100644 index 00000000000..96c6cfa8c18 --- /dev/null +++ b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/titan/BedrockTitanEmbeddingAutoConfiguration.java @@ -0,0 +1,69 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.bedrock.titan; + +import com.fasterxml.jackson.databind.ObjectMapper; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.regions.providers.AwsRegionProvider; + +import org.springframework.ai.autoconfigure.bedrock.BedrockAwsConnectionConfiguration; +import org.springframework.ai.autoconfigure.bedrock.BedrockAwsConnectionProperties; +import org.springframework.ai.bedrock.titan.BedrockTitanEmbeddingModel; +import org.springframework.ai.bedrock.titan.api.TitanEmbeddingBedrockApi; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +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.context.annotation.Bean; +import org.springframework.context.annotation.Import; + +/** + * {@link AutoConfiguration Auto-configuration} for Bedrock Titan Embedding Model. + * + * @author Christian Tzolov + * @author Wei Jiang + * @since 0.8.0 + */ +@AutoConfiguration +@ConditionalOnClass(TitanEmbeddingBedrockApi.class) +@EnableConfigurationProperties({ BedrockTitanEmbeddingProperties.class, BedrockAwsConnectionProperties.class }) +@ConditionalOnProperty(prefix = BedrockTitanEmbeddingProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true") +@Import(BedrockAwsConnectionConfiguration.class) +public class BedrockTitanEmbeddingAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + @ConditionalOnBean({ AwsCredentialsProvider.class, AwsRegionProvider.class }) + public TitanEmbeddingBedrockApi titanEmbeddingBedrockApi(AwsCredentialsProvider credentialsProvider, + AwsRegionProvider regionProvider, BedrockTitanEmbeddingProperties properties, + BedrockAwsConnectionProperties awsProperties, ObjectMapper objectMapper) { + return new TitanEmbeddingBedrockApi(properties.getModel(), credentialsProvider, regionProvider.getRegion(), + objectMapper, awsProperties.getTimeout()); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnBean(TitanEmbeddingBedrockApi.class) + public BedrockTitanEmbeddingModel titanEmbeddingModel(TitanEmbeddingBedrockApi titanEmbeddingApi, + BedrockTitanEmbeddingProperties properties) { + + return new BedrockTitanEmbeddingModel(titanEmbeddingApi).withInputType(properties.getInputType()); + } + +} diff --git a/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/titan/BedrockTitanEmbeddingProperties.java b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/titan/BedrockTitanEmbeddingProperties.java new file mode 100644 index 00000000000..b0c1e604861 --- /dev/null +++ b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/titan/BedrockTitanEmbeddingProperties.java @@ -0,0 +1,78 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.bedrock.titan; + +import org.springframework.ai.bedrock.titan.BedrockTitanEmbeddingModel.InputType; +import org.springframework.ai.bedrock.titan.api.TitanEmbeddingBedrockApi.TitanEmbeddingModel; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Bedrock Titan Embedding autoconfiguration properties. + * + * @author Christian Tzolov + * @since 0.8.0 + */ +@ConfigurationProperties(BedrockTitanEmbeddingProperties.CONFIG_PREFIX) +public class BedrockTitanEmbeddingProperties { + + public static final String CONFIG_PREFIX = "spring.ai.bedrock.titan.embedding"; + + /** + * Enable Bedrock Titan Embedding Model. False by default. + */ + private boolean enabled = false; + + /** + * Bedrock Titan Embedding generative name. Defaults to 'amazon.titan-embed-image-v1'. + */ + private String model = TitanEmbeddingModel.TITAN_EMBED_IMAGE_V1.id(); + + /** + * Titan Embedding API input types. Could be either text or image (encoded in base64). + * Defaults to {@link InputType#IMAGE}. + */ + private InputType inputType = InputType.IMAGE; + + public static String getConfigPrefix() { + return CONFIG_PREFIX; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + 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; + } + +} diff --git a/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..3e29358e68d --- /dev/null +++ b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,18 @@ +# +# 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.autoconfigure.bedrock.cohere.BedrockCohereEmbeddingAutoConfiguration +org.springframework.ai.autoconfigure.bedrock.titan.BedrockTitanEmbeddingAutoConfiguration +org.springframework.ai.autoconfigure.bedrock.converse.BedrockConverseProxyChatAutoConfiguration diff --git a/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/BedrockAwsConnectionConfigurationIT.java b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/BedrockAwsConnectionConfigurationIT.java new file mode 100644 index 00000000000..70d480bc150 --- /dev/null +++ b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/BedrockAwsConnectionConfigurationIT.java @@ -0,0 +1,132 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.bedrock; + +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.auth.credentials.AwsCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.regions.providers.AwsRegionProvider; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Wei Jiang + * @author Mark Pollack + * @since 1.0.0 + */ +@RequiresAwsCredentials +public class BedrockAwsConnectionConfigurationIT { + + @Test + public void autoConfigureAWSCredentialAndRegionProvider() { + BedrockTestUtils.getContextRunner() + .withConfiguration(AutoConfigurations.of(TestAutoConfiguration.class)) + .run(context -> { + var awsCredentialsProvider = context.getBean(AwsCredentialsProvider.class); + var awsRegionProvider = context.getBean(AwsRegionProvider.class); + + assertThat(awsCredentialsProvider).isNotNull(); + assertThat(awsRegionProvider).isNotNull(); + + var credentials = awsCredentialsProvider.resolveCredentials(); + assertThat(credentials).isNotNull(); + assertThat(credentials.accessKeyId()).isEqualTo(System.getenv("AWS_ACCESS_KEY_ID")); + assertThat(credentials.secretAccessKey()).isEqualTo(System.getenv("AWS_SECRET_ACCESS_KEY")); + + assertThat(awsRegionProvider.getRegion()).isEqualTo(Region.US_EAST_1); + }); + } + + @Test + public void autoConfigureWithCustomAWSCredentialAndRegionProvider() { + BedrockTestUtils.getContextRunner() + .withConfiguration(AutoConfigurations.of(TestAutoConfiguration.class, + CustomAwsCredentialsProviderAndAwsRegionProviderAutoConfiguration.class)) + .run(context -> { + var awsCredentialsProvider = context.getBean(AwsCredentialsProvider.class); + var awsRegionProvider = context.getBean(AwsRegionProvider.class); + + assertThat(awsCredentialsProvider).isNotNull(); + assertThat(awsRegionProvider).isNotNull(); + + var credentials = awsCredentialsProvider.resolveCredentials(); + assertThat(credentials).isNotNull(); + assertThat(credentials.accessKeyId()).isEqualTo("CUSTOM_ACCESS_KEY"); + assertThat(credentials.secretAccessKey()).isEqualTo("CUSTOM_SECRET_ACCESS_KEY"); + + assertThat(awsRegionProvider.getRegion()).isEqualTo(Region.AWS_GLOBAL); + }); + } + + @EnableConfigurationProperties({ BedrockAwsConnectionProperties.class }) + @Import(BedrockAwsConnectionConfiguration.class) + static class TestAutoConfiguration { + + } + + @AutoConfiguration + static class CustomAwsCredentialsProviderAndAwsRegionProviderAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public AwsCredentialsProvider credentialsProvider() { + return new AwsCredentialsProvider() { + + @Override + public AwsCredentials resolveCredentials() { + return new AwsCredentials() { + + @Override + public String accessKeyId() { + return "CUSTOM_ACCESS_KEY"; + } + + @Override + public String secretAccessKey() { + return "CUSTOM_SECRET_ACCESS_KEY"; + } + + }; + } + + }; + } + + @Bean + @ConditionalOnMissingBean + public AwsRegionProvider regionProvider() { + return new AwsRegionProvider() { + + @Override + public Region getRegion() { + return Region.AWS_GLOBAL; + } + + }; + } + + } + +} diff --git a/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/BedrockTestUtils.java b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/BedrockTestUtils.java new file mode 100644 index 00000000000..991da658134 --- /dev/null +++ b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/BedrockTestUtils.java @@ -0,0 +1,54 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.bedrock; + +import com.fasterxml.jackson.databind.ObjectMapper; +import software.amazon.awssdk.regions.Region; + +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +public final class BedrockTestUtils { + + private BedrockTestUtils() { + } // Prevent instantiation + + public static ApplicationContextRunner getContextRunner() { + return new ApplicationContextRunner() + .withPropertyValues("spring.ai.bedrock.aws.access-key=" + System.getenv("AWS_ACCESS_KEY_ID"), + "spring.ai.bedrock.aws.secret-key=" + System.getenv("AWS_SECRET_ACCESS_KEY"), + "spring.ai.bedrock.aws.session-token=" + System.getenv("AWS_SESSION_TOKEN"), + "spring.ai.bedrock.aws.region=" + Region.US_EAST_1.id()) + .withUserConfiguration(Config.class); + } + + public static ApplicationContextRunner getContextRunnerWithUserConfiguration() { + return new ApplicationContextRunner().withUserConfiguration(Config.class); + } + + @Configuration + static class Config { + + @Bean + public ObjectMapper objectMapper() { + return new ObjectMapper(); + } + + } + +} diff --git a/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/RequiresAwsCredentials.java b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/RequiresAwsCredentials.java new file mode 100644 index 00000000000..006cb4e1690 --- /dev/null +++ b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/RequiresAwsCredentials.java @@ -0,0 +1,35 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.bedrock; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +@Target({ ElementType.TYPE, ElementType.METHOD }) +@Retention(RetentionPolicy.RUNTIME) +@EnabledIfEnvironmentVariable(named = "AWS_ACCESS_KEY_ID", matches = ".*") +@EnabledIfEnvironmentVariable(named = "AWS_SECRET_ACCESS_KEY", matches = ".*") +@EnabledIfEnvironmentVariable(named = "AWS_SESSION_TOKEN", matches = ".*") +public @interface RequiresAwsCredentials { + + // You can add custom properties here if needed + +} diff --git a/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/cohere/BedrockCohereEmbeddingAutoConfigurationIT.java b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/cohere/BedrockCohereEmbeddingAutoConfigurationIT.java new file mode 100644 index 00000000000..0ed6cddf4ea --- /dev/null +++ b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/cohere/BedrockCohereEmbeddingAutoConfigurationIT.java @@ -0,0 +1,141 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.bedrock.cohere; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.regions.Region; + +import org.springframework.ai.autoconfigure.bedrock.BedrockAwsConnectionProperties; +import org.springframework.ai.autoconfigure.bedrock.BedrockTestUtils; +import org.springframework.ai.autoconfigure.bedrock.RequiresAwsCredentials; +import org.springframework.ai.bedrock.cohere.BedrockCohereEmbeddingModel; +import org.springframework.ai.bedrock.cohere.api.CohereEmbeddingBedrockApi.CohereEmbeddingModel; +import org.springframework.ai.bedrock.cohere.api.CohereEmbeddingBedrockApi.CohereEmbeddingRequest; +import org.springframework.ai.bedrock.cohere.api.CohereEmbeddingBedrockApi.CohereEmbeddingRequest.InputType; +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Christian Tzolov + * @author Mark Pollack + * @since 1.0.0 + */ +@RequiresAwsCredentials +public class BedrockCohereEmbeddingAutoConfigurationIT { + + private final ApplicationContextRunner contextRunner = BedrockTestUtils.getContextRunner() + .withPropertyValues("spring.ai.bedrock.cohere.embedding.enabled=true", + "spring.ai.bedrock.cohere.embedding.model=" + CohereEmbeddingModel.COHERE_EMBED_MULTILINGUAL_V3.id(), + "spring.ai.bedrock.cohere.embedding.options.inputType=SEARCH_DOCUMENT", + "spring.ai.bedrock.cohere.embedding.options.truncate=NONE") + .withConfiguration(AutoConfigurations.of(BedrockCohereEmbeddingAutoConfiguration.class)); + + @Test + public void singleEmbedding() { + this.contextRunner.run(context -> { + BedrockCohereEmbeddingModel embeddingModel = context.getBean(BedrockCohereEmbeddingModel.class); + assertThat(embeddingModel).isNotNull(); + EmbeddingResponse embeddingResponse = embeddingModel.embedForResponse(List.of("Hello World")); + assertThat(embeddingResponse.getResults()).hasSize(1); + assertThat(embeddingResponse.getResults().get(0).getOutput()).isNotEmpty(); + assertThat(embeddingModel.dimensions()).isEqualTo(1024); + }); + } + + @Test + public void batchEmbedding() { + this.contextRunner.run(context -> { + + BedrockCohereEmbeddingModel embeddingModel = context.getBean(BedrockCohereEmbeddingModel.class); + + assertThat(embeddingModel).isNotNull(); + 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(1024); + + }); + } + + @Test + public void propertiesTest() { + + BedrockTestUtils.getContextRunnerWithUserConfiguration() + .withPropertyValues("spring.ai.bedrock.cohere.embedding.enabled=true", + "spring.ai.bedrock.aws.access-key=ACCESS_KEY", "spring.ai.bedrock.aws.secret-key=SECRET_KEY", + "spring.ai.bedrock.aws.region=" + Region.US_EAST_1.id(), + "spring.ai.bedrock.cohere.embedding.model=MODEL_XYZ", + "spring.ai.bedrock.cohere.embedding.options.inputType=CLASSIFICATION", + "spring.ai.bedrock.cohere.embedding.options.truncate=START") + .withConfiguration(AutoConfigurations.of(BedrockCohereEmbeddingAutoConfiguration.class)) + .run(context -> { + var properties = context.getBean(BedrockCohereEmbeddingProperties.class); + var awsProperties = context.getBean(BedrockAwsConnectionProperties.class); + + assertThat(properties.isEnabled()).isTrue(); + assertThat(awsProperties.getRegion()).isEqualTo(Region.US_EAST_1.id()); + assertThat(properties.getModel()).isEqualTo("MODEL_XYZ"); + + assertThat(properties.getOptions().getInputType()).isEqualTo(InputType.CLASSIFICATION); + assertThat(properties.getOptions().getTruncate()).isEqualTo(CohereEmbeddingRequest.Truncate.START); + + assertThat(awsProperties.getAccessKey()).isEqualTo("ACCESS_KEY"); + assertThat(awsProperties.getSecretKey()).isEqualTo("SECRET_KEY"); + }); + } + + @Test + public void embeddingDisabled() { + + // It is disabled by default + BedrockTestUtils.getContextRunnerWithUserConfiguration() + .withConfiguration(AutoConfigurations.of(BedrockCohereEmbeddingAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(BedrockCohereEmbeddingProperties.class)).isEmpty(); + assertThat(context.getBeansOfType(BedrockCohereEmbeddingModel.class)).isEmpty(); + }); + + // Explicitly enable the embedding auto-configuration. + BedrockTestUtils.getContextRunnerWithUserConfiguration() + .withPropertyValues("spring.ai.bedrock.cohere.embedding.enabled=true") + .withConfiguration(AutoConfigurations.of(BedrockCohereEmbeddingAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(BedrockCohereEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(BedrockCohereEmbeddingModel.class)).isNotEmpty(); + }); + + // Explicitly disable the embedding auto-configuration. + BedrockTestUtils.getContextRunnerWithUserConfiguration() + .withPropertyValues("spring.ai.bedrock.cohere.embedding.enabled=false") + .withConfiguration(AutoConfigurations.of(BedrockCohereEmbeddingAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(BedrockCohereEmbeddingProperties.class)).isEmpty(); + assertThat(context.getBeansOfType(BedrockCohereEmbeddingModel.class)).isEmpty(); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/converse/BedrockConverseProxyChatAutoConfigurationIT.java b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/converse/BedrockConverseProxyChatAutoConfigurationIT.java new file mode 100644 index 00000000000..936f4b59f6e --- /dev/null +++ b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/converse/BedrockConverseProxyChatAutoConfigurationIT.java @@ -0,0 +1,81 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.bedrock.converse; + +import java.util.List; +import java.util.stream.Collectors; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; + +import org.springframework.ai.autoconfigure.bedrock.BedrockTestUtils; +import org.springframework.ai.autoconfigure.bedrock.RequiresAwsCredentials; +import org.springframework.ai.bedrock.converse.BedrockProxyChatModel; +import org.springframework.ai.chat.messages.AssistantMessage; +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.Prompt; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +@RequiresAwsCredentials +public class BedrockConverseProxyChatAutoConfigurationIT { + + private static final Log logger = LogFactory.getLog(BedrockConverseProxyChatAutoConfigurationIT.class); + + private final ApplicationContextRunner contextRunner = BedrockTestUtils.getContextRunner() + .withPropertyValues( + "spring.ai.bedrock.converse.chat.options.model=" + "anthropic.claude-3-5-sonnet-20240620-v1:0", + "spring.ai.bedrock.converse.chat.options.temperature=0.5") + .withConfiguration(AutoConfigurations.of(BedrockConverseProxyChatAutoConfiguration.class)); + + @Test + void call() { + this.contextRunner.run(context -> { + BedrockProxyChatModel chatModel = context.getBean(BedrockProxyChatModel.class); + String response = chatModel.call("Hello"); + assertThat(response).isNotEmpty(); + logger.info("Response: " + response); + }); + } + + @Test + void stream() { + this.contextRunner.run(context -> { + BedrockProxyChatModel chatModel = context.getBean(BedrockProxyChatModel.class); + Flux responseFlux = chatModel.stream(new Prompt(new UserMessage("Hello"))); + + String response = responseFlux.collectList() + .block() + .stream() + .map(ChatResponse::getResults) + .flatMap(List::stream) + .map(Generation::getOutput) + .map(AssistantMessage::getText) + .collect(Collectors.joining()); + + assertThat(response).isNotEmpty(); + logger.info("Response: " + response); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/converse/BedrockConverseProxyChatPropertiesTests.java b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/converse/BedrockConverseProxyChatPropertiesTests.java new file mode 100644 index 00000000000..4b4bb809a44 --- /dev/null +++ b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/converse/BedrockConverseProxyChatPropertiesTests.java @@ -0,0 +1,84 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.bedrock.converse; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Christian Tzolov + * + * Unit Tests for {@link BedrockConverseProxyChatProperties}. + */ +public class BedrockConverseProxyChatPropertiesTests { + + @Test + public void chatOptionsTest() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.bedrock.converse.chat.options.model=MODEL_XYZ", + + "spring.ai.bedrock.converse.chat.options.max-tokens=123", + "spring.ai.bedrock.converse.chat.options.metadata.user-id=MyUserId", + "spring.ai.bedrock.converse.chat.options.stop_sequences=boza,koza", + + "spring.ai.bedrock.converse.chat.options.temperature=0.55", + "spring.ai.bedrock.converse.chat.options.top-p=0.56", + "spring.ai.bedrock.converse.chat.options.top-k=100" + ) + // @formatter:on + .withConfiguration(AutoConfigurations.of(BedrockConverseProxyChatAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(BedrockConverseProxyChatProperties.class); + + assertThat(chatProperties.isEnabled()).isTrue(); + + assertThat(chatProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(chatProperties.getOptions().getMaxTokens()).isEqualTo(123); + assertThat(chatProperties.getOptions().getStopSequences()).contains("boza", "koza"); + assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55); + assertThat(chatProperties.getOptions().getTopP()).isEqualTo(0.56); + assertThat(chatProperties.getOptions().getTopK()).isEqualTo(100); + + }); + } + + @Test + public void chatCompletionDisabled() { + + // It is enabled by default + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(BedrockConverseProxyChatAutoConfiguration.class)) + .run(context -> assertThat(context.getBeansOfType(BedrockConverseProxyChatProperties.class)).isNotEmpty()); + + // Explicitly enable the chat auto-configuration. + new ApplicationContextRunner().withPropertyValues("spring.ai.bedrock.converse.chat.enabled=true") + .withConfiguration(AutoConfigurations.of(BedrockConverseProxyChatAutoConfiguration.class)) + .run(context -> assertThat(context.getBeansOfType(BedrockConverseProxyChatProperties.class)).isNotEmpty()); + + // Explicitly disable the chat auto-configuration. + new ApplicationContextRunner().withPropertyValues("spring.ai.bedrock.converse.chat.enabled=false") + .withConfiguration(AutoConfigurations.of(BedrockConverseProxyChatAutoConfiguration.class)) + .run(context -> assertThat(context.getBeansOfType(BedrockConverseProxyChatProperties.class)).isEmpty()); + } + +} diff --git a/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/converse/tool/FunctionCallWithFunctionBeanIT.java b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/converse/tool/FunctionCallWithFunctionBeanIT.java new file mode 100644 index 00000000000..6344b3e81fd --- /dev/null +++ b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/converse/tool/FunctionCallWithFunctionBeanIT.java @@ -0,0 +1,130 @@ +/* + * 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.autoconfigure.bedrock.converse.tool; + +import java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; + +import org.springframework.ai.autoconfigure.bedrock.BedrockTestUtils; +import org.springframework.ai.autoconfigure.bedrock.RequiresAwsCredentials; +import org.springframework.ai.autoconfigure.bedrock.converse.BedrockConverseProxyChatAutoConfiguration; +import org.springframework.ai.bedrock.converse.BedrockProxyChatModel; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.model.tool.ToolCallingChatOptions; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Description; + +import static org.assertj.core.api.Assertions.assertThat; + +@RequiresAwsCredentials +class FunctionCallWithFunctionBeanIT { + + private final Logger logger = LoggerFactory.getLogger(FunctionCallWithFunctionBeanIT.class); + + private final ApplicationContextRunner contextRunner = BedrockTestUtils.getContextRunner() + .withConfiguration(AutoConfigurations.of(BedrockConverseProxyChatAutoConfiguration.class)) + .withUserConfiguration(Config.class); + + @Test + void functionCallTest() { + + this.contextRunner + .withPropertyValues( + "spring.ai.bedrock.converse.chat.options.model=" + "anthropic.claude-3-5-sonnet-20240620-v1:0") + .run(context -> { + + BedrockProxyChatModel chatModel = context.getBean(BedrockProxyChatModel.class); + + var userMessage = new UserMessage( + "What's the weather like in San Francisco, in Paris, France and in Tokyo, Japan? Return the temperature in Celsius."); + + ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), + ToolCallingChatOptions.builder().toolNames("weatherFunction").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + + response = chatModel.call(new Prompt(List.of(userMessage), + ToolCallingChatOptions.builder().toolNames("weatherFunction3").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + }); + } + + @Test + void functionStreamTest() { + + this.contextRunner + .withPropertyValues( + "spring.ai.bedrock.converse.chat.options.model=" + "anthropic.claude-3-5-sonnet-20240620-v1:0") + .run(context -> { + + BedrockProxyChatModel chatModel = context.getBean(BedrockProxyChatModel.class); + + var userMessage = new UserMessage( + "What's the weather like in San Francisco, in Paris, France and in Tokyo, Japan? Return the temperature in Celsius."); + + Flux responses = chatModel.stream(new Prompt(List.of(userMessage), + ToolCallingChatOptions.builder().toolNames("weatherFunction").build())); + + String content = responses.collectList() + .block() + .stream() + .filter(cr -> cr.getResult() != null) + .map(cr -> cr.getResult().getOutput().getText()) + .collect(Collectors.joining()); + + logger.info("Response: {}", content); + assertThat(content).contains("30", "10", "15"); + + }); + } + + @Configuration + static class Config { + + @Bean + @Description("Get the weather in location. Return temperature in 36°F or 36°C format.") + public Function weatherFunction() { + return new MockWeatherService(); + } + + // Relies on the Request's JsonClassDescription annotation to provide the + // function description. + @Bean + public Function weatherFunction3() { + MockWeatherService weatherService = new MockWeatherService(); + return (weatherService::apply); + } + + } + +} diff --git a/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/converse/tool/FunctionCallWithPromptFunctionIT.java b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/converse/tool/FunctionCallWithPromptFunctionIT.java new file mode 100644 index 00000000000..62af91d2996 --- /dev/null +++ b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/converse/tool/FunctionCallWithPromptFunctionIT.java @@ -0,0 +1,75 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.bedrock.converse.tool; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.autoconfigure.bedrock.BedrockTestUtils; +import org.springframework.ai.autoconfigure.bedrock.RequiresAwsCredentials; +import org.springframework.ai.autoconfigure.bedrock.converse.BedrockConverseProxyChatAutoConfiguration; +import org.springframework.ai.bedrock.converse.BedrockProxyChatModel; +import org.springframework.ai.chat.messages.UserMessage; +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.tool.function.FunctionToolCallback; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +@RequiresAwsCredentials +public class FunctionCallWithPromptFunctionIT { + + private final Logger logger = LoggerFactory.getLogger(FunctionCallWithPromptFunctionIT.class); + + private final ApplicationContextRunner contextRunner = BedrockTestUtils.getContextRunner() + .withConfiguration(AutoConfigurations.of(BedrockConverseProxyChatAutoConfiguration.class)); + + @Test + void functionCallTest() { + this.contextRunner + .withPropertyValues( + "spring.ai.bedrock.converse.chat.options.model=" + "anthropic.claude-3-5-sonnet-20240620-v1:0") + .run(context -> { + + BedrockProxyChatModel chatModel = context.getBean(BedrockProxyChatModel.class); + + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, in Paris and in Tokyo? Return the temperature in Celsius."); + + var promptOptions = ToolCallingChatOptions.builder() + .toolCallbacks( + List.of(FunctionToolCallback.builder("CurrentWeatherService", new MockWeatherService()) + .description("Get the weather in location. Return temperature in 36°F or 36°C format.") + .inputType(MockWeatherService.Request.class) + .build())) + .build(); + + ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), promptOptions)); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/converse/tool/MockWeatherService.java b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/converse/tool/MockWeatherService.java new file mode 100644 index 00000000000..176d42f66b3 --- /dev/null +++ b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/converse/tool/MockWeatherService.java @@ -0,0 +1,95 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.bedrock.converse.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; + +/** + * Mock 3rd party weather service. + * + * @author Christian Tzolov + */ +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/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/titan/BedrockTitanEmbeddingAutoConfigurationIT.java b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/titan/BedrockTitanEmbeddingAutoConfigurationIT.java new file mode 100644 index 00000000000..31ca213da9c --- /dev/null +++ b/auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/titan/BedrockTitanEmbeddingAutoConfigurationIT.java @@ -0,0 +1,140 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.bedrock.titan; + +import java.util.Base64; +import java.util.List; + +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.regions.Region; + +import org.springframework.ai.autoconfigure.bedrock.BedrockAwsConnectionProperties; +import org.springframework.ai.autoconfigure.bedrock.BedrockTestUtils; +import org.springframework.ai.autoconfigure.bedrock.RequiresAwsCredentials; +import org.springframework.ai.bedrock.titan.BedrockTitanEmbeddingModel; +import org.springframework.ai.bedrock.titan.BedrockTitanEmbeddingModel.InputType; +import org.springframework.ai.bedrock.titan.api.TitanEmbeddingBedrockApi.TitanEmbeddingModel; +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.core.io.DefaultResourceLoader; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Christian Tzolov + * @author Mark Pollack + * @since 1.0.0 + */ +@RequiresAwsCredentials +public class BedrockTitanEmbeddingAutoConfigurationIT { + + private final ApplicationContextRunner contextRunner = BedrockTestUtils.getContextRunner() + .withPropertyValues("spring.ai.bedrock.titan.embedding.enabled=true", + "spring.ai.bedrock.aws.access-key=" + System.getenv("AWS_ACCESS_KEY_ID"), + "spring.ai.bedrock.aws.secret-key=" + System.getenv("AWS_SECRET_ACCESS_KEY"), + "spring.ai.bedrock.aws.region=" + Region.US_EAST_1.id(), + "spring.ai.bedrock.titan.embedding.model=" + TitanEmbeddingModel.TITAN_EMBED_IMAGE_V1.id()) + .withConfiguration(AutoConfigurations.of(BedrockTitanEmbeddingAutoConfiguration.class)); + + @Test + public void singleTextEmbedding() { + this.contextRunner.withPropertyValues("spring.ai.bedrock.titan.embedding.inputType=TEXT").run(context -> { + BedrockTitanEmbeddingModel embeddingModel = context.getBean(BedrockTitanEmbeddingModel.class); + assertThat(embeddingModel).isNotNull(); + EmbeddingResponse embeddingResponse = embeddingModel.embedForResponse(List.of("Hello World")); + assertThat(embeddingResponse.getResults()).hasSize(1); + assertThat(embeddingResponse.getResults().get(0).getOutput()).isNotEmpty(); + assertThat(embeddingModel.dimensions()).isEqualTo(1024); + }); + } + + @Test + public void singleImageEmbedding() { + this.contextRunner.withPropertyValues("spring.ai.bedrock.titan.embedding.inputType=IMAGE").run(context -> { + BedrockTitanEmbeddingModel embeddingModel = context.getBean(BedrockTitanEmbeddingModel.class); + assertThat(embeddingModel).isNotNull(); + + byte[] image = new DefaultResourceLoader().getResource("classpath:/spring_framework.png") + .getContentAsByteArray(); + + var base64Image = Base64.getEncoder().encodeToString(image); + + EmbeddingResponse embeddingResponse = embeddingModel.embedForResponse(List.of(base64Image)); + + assertThat(embeddingResponse.getResults()).hasSize(1); + assertThat(embeddingResponse.getResults().get(0).getOutput()).isNotEmpty(); + assertThat(embeddingModel.dimensions()).isEqualTo(1024); + }); + } + + @Test + public void propertiesTest() { + + BedrockTestUtils.getContextRunnerWithUserConfiguration() + .withPropertyValues("spring.ai.bedrock.titan.embedding.enabled=true", + "spring.ai.bedrock.aws.access-key=ACCESS_KEY", "spring.ai.bedrock.aws.secret-key=SECRET_KEY", + "spring.ai.bedrock.aws.region=" + Region.US_EAST_1.id(), + "spring.ai.bedrock.titan.embedding.model=MODEL_XYZ", + "spring.ai.bedrock.titan.embedding.inputType=TEXT") + .withConfiguration(AutoConfigurations.of(BedrockTitanEmbeddingAutoConfiguration.class)) + .run(context -> { + var properties = context.getBean(BedrockTitanEmbeddingProperties.class); + var awsProperties = context.getBean(BedrockAwsConnectionProperties.class); + + assertThat(properties.isEnabled()).isTrue(); + assertThat(awsProperties.getRegion()).isEqualTo(Region.US_EAST_1.id()); + assertThat(properties.getModel()).isEqualTo("MODEL_XYZ"); + + assertThat(properties.getInputType()).isEqualTo(InputType.TEXT); + + assertThat(awsProperties.getAccessKey()).isEqualTo("ACCESS_KEY"); + assertThat(awsProperties.getSecretKey()).isEqualTo("SECRET_KEY"); + }); + } + + @Test + public void embeddingDisabled() { + + // It is disabled by default + BedrockTestUtils.getContextRunnerWithUserConfiguration() + .withConfiguration(AutoConfigurations.of(BedrockTitanEmbeddingAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(BedrockTitanEmbeddingProperties.class)).isEmpty(); + assertThat(context.getBeansOfType(BedrockTitanEmbeddingModel.class)).isEmpty(); + }); + + // Explicitly enable the embedding auto-configuration. + BedrockTestUtils.getContextRunnerWithUserConfiguration() + .withPropertyValues("spring.ai.bedrock.titan.embedding.enabled=true") + .withConfiguration(AutoConfigurations.of(BedrockTitanEmbeddingAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(BedrockTitanEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(BedrockTitanEmbeddingModel.class)).isNotEmpty(); + }); + + // Explicitly disable the embedding auto-configuration. + BedrockTestUtils.getContextRunnerWithUserConfiguration() + .withPropertyValues("spring.ai.bedrock.titan.embedding.enabled=false") + .withConfiguration(AutoConfigurations.of(BedrockTitanEmbeddingAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(BedrockTitanEmbeddingProperties.class)).isEmpty(); + assertThat(context.getBeansOfType(BedrockTitanEmbeddingModel.class)).isEmpty(); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-huggingface-spring-boot-autoconfigure/pom.xml b/auto-configurations/models/spring-ai-huggingface-spring-boot-autoconfigure/pom.xml new file mode 100644 index 00000000000..beb6698f19d --- /dev/null +++ b/auto-configurations/models/spring-ai-huggingface-spring-boot-autoconfigure/pom.xml @@ -0,0 +1,86 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../../pom.xml + + spring-ai-huggingface-spring-boot-autoconfigure + jar + Spring AI Huggingface Auto Configuration + Spring AI Huggingface 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-huggingface + ${project.parent.version} + true + + + + + + org.springframework.ai + spring-ai-chat-client-spring-boot-autoconfigure + ${project.parent.version} + true + + + + + org.springframework.boot + spring-boot-starter + true + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + + org.springframework.ai + spring-ai-test + ${project.parent.version} + test + + + + org.springframework.ai + spring-ai-openai-spring-boot-autoconfigure + ${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-huggingface-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/huggingface/HuggingfaceChatAutoConfiguration.java b/auto-configurations/models/spring-ai-huggingface-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/huggingface/HuggingfaceChatAutoConfiguration.java new file mode 100644 index 00000000000..eaf74d75f86 --- /dev/null +++ b/auto-configurations/models/spring-ai-huggingface-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/huggingface/HuggingfaceChatAutoConfiguration.java @@ -0,0 +1,40 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.huggingface; + +import org.springframework.ai.huggingface.HuggingfaceChatModel; +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.context.annotation.Bean; + +@AutoConfiguration +@ConditionalOnClass(HuggingfaceChatModel.class) +@EnableConfigurationProperties(HuggingfaceChatProperties.class) +public class HuggingfaceChatAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = HuggingfaceChatProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + public HuggingfaceChatModel huggingfaceChatModel(HuggingfaceChatProperties huggingfaceChatProperties) { + return new HuggingfaceChatModel(huggingfaceChatProperties.getApiKey(), huggingfaceChatProperties.getUrl()); + } + +} diff --git a/auto-configurations/models/spring-ai-huggingface-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/huggingface/HuggingfaceChatProperties.java b/auto-configurations/models/spring-ai-huggingface-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/huggingface/HuggingfaceChatProperties.java new file mode 100644 index 00000000000..ff7fda7b066 --- /dev/null +++ b/auto-configurations/models/spring-ai-huggingface-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/huggingface/HuggingfaceChatProperties.java @@ -0,0 +1,73 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.huggingface; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration properties for Hugging Face chat model. + * + * @author Christian Tzolov + * @author Josh Long + * @author Mark Pollack + * @author Thomas Vitale + */ +@ConfigurationProperties(HuggingfaceChatProperties.CONFIG_PREFIX) +public class HuggingfaceChatProperties { + + public static final String CONFIG_PREFIX = "spring.ai.huggingface.chat"; + + /** + * API Key to authenticate with the Inference Endpoint. + */ + private String apiKey; + + /** + * URL of the Inference Endpoint. + */ + private String url; + + /** + * Enable Hugging Face chat model. + */ + private boolean enabled = true; + + public String getApiKey() { + return this.apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getUrl() { + return this.url; + } + + public void setUrl(String url) { + this.url = url; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + +} diff --git a/auto-configurations/models/spring-ai-huggingface-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/models/spring-ai-huggingface-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..a084b2ad18d --- /dev/null +++ b/auto-configurations/models/spring-ai-huggingface-spring-boot-autoconfigure/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.autoconfigure.huggingface.HuggingfaceChatAutoConfiguration diff --git a/auto-configurations/models/spring-ai-huggingface-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/huggingface/HuggingfaceChatAutoConfigurationIT.java b/auto-configurations/models/spring-ai-huggingface-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/huggingface/HuggingfaceChatAutoConfigurationIT.java new file mode 100644 index 00000000000..e3980a13ab0 --- /dev/null +++ b/auto-configurations/models/spring-ai-huggingface-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/huggingface/HuggingfaceChatAutoConfigurationIT.java @@ -0,0 +1,84 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.huggingface; + +import java.util.List; +import java.util.stream.Collectors; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import reactor.core.publisher.Flux; + +import org.springframework.ai.chat.messages.AssistantMessage; +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.Prompt; +import org.springframework.ai.huggingface.HuggingfaceChatModel; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +@EnabledIfEnvironmentVariable(named = "HUGGINGFACE_API_KEY", matches = ".+") +@EnabledIfEnvironmentVariable(named = "HUGGINGFACE_CHAT_URL", matches = ".+") +public class HuggingfaceChatAutoConfigurationIT { + + private static final Log logger = LogFactory.getLog(HuggingfaceChatAutoConfigurationIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.huggingface.chat.api-key=" + System.getenv("HUGGINGFACE_API_KEY"), + "spring.ai.huggingface.chat.url=" + System.getenv("HUGGINGFACE_CHAT_URL")) + // @formatter:on + .withConfiguration(AutoConfigurations.of(HuggingfaceChatAutoConfiguration.class)); + + @Test + void generate() { + this.contextRunner.run(context -> { + HuggingfaceChatModel chatModel = context.getBean(HuggingfaceChatModel.class); + String response = chatModel.call("Hello"); + assertThat(response).isNotEmpty(); + logger.info("Response: " + response); + }); + } + + @Disabled("Until streaming support is added") + @Test + void generateStreaming() { + this.contextRunner.run(context -> { + HuggingfaceChatModel chatModel = context.getBean(HuggingfaceChatModel.class); + Flux responseFlux = chatModel.stream(new Prompt(new UserMessage("Hello"))); + + String response = responseFlux.collectList() + .block() + .stream() + .map(ChatResponse::getResults) + .flatMap(List::stream) + .map(Generation::getOutput) + .map(AssistantMessage::getText) + .collect(Collectors.joining()); + + assertThat(response).isNotEmpty(); + logger.info("Response: " + response); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/pom.xml b/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/pom.xml new file mode 100644 index 00000000000..e8535d75dbd --- /dev/null +++ b/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/pom.xml @@ -0,0 +1,93 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../../pom.xml + + spring-ai-minimax-spring-boot-autoconfigure + jar + Spring AI Minimax Auto Configuration + Spring AI Minimax 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-minimax + ${project.parent.version} + true + + + + + + org.springframework.ai + spring-ai-chat-client-spring-boot-autoconfigure + ${project.parent.version} + true + + + + org.springframework.ai + spring-ai-chat-model-spring-boot-autoconfigure + ${project.parent.version} + true + + + + org.springframework.ai + spring-ai-retry-spring-boot-autoconfigure + ${project.parent.version} + true + + + + + org.springframework.boot + spring-boot-starter + true + + + + org.springframework.boot + spring-boot-configuration-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-minimax-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxAutoConfiguration.java b/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxAutoConfiguration.java new file mode 100644 index 00000000000..b47536a6cdf --- /dev/null +++ b/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxAutoConfiguration.java @@ -0,0 +1,123 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.minimax; + +import java.util.List; + +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.chat.observation.ChatModelObservationConvention; +import org.springframework.ai.embedding.observation.EmbeddingModelObservationConvention; +import org.springframework.ai.minimax.MiniMaxChatModel; +import org.springframework.ai.minimax.MiniMaxEmbeddingModel; +import org.springframework.ai.minimax.api.MiniMaxApi; +import org.springframework.ai.model.function.DefaultFunctionCallbackResolver; +import org.springframework.ai.model.function.FunctionCallback; +import org.springframework.ai.model.function.FunctionCallbackResolver; +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.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.web.client.ResponseErrorHandler; +import org.springframework.web.client.RestClient; + +/** + * {@link AutoConfiguration Auto-configuration} for MiniMax Chat and Embedding Models. + * + * @author Geng Rong + */ +@AutoConfiguration(after = { RestClientAutoConfiguration.class, SpringAiRetryAutoConfiguration.class }) +@ConditionalOnClass(MiniMaxApi.class) +@EnableConfigurationProperties({ MiniMaxConnectionProperties.class, MiniMaxChatProperties.class, + MiniMaxEmbeddingProperties.class }) +public class MiniMaxAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = MiniMaxChatProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + public MiniMaxChatModel miniMaxChatModel(MiniMaxConnectionProperties commonProperties, + MiniMaxChatProperties chatProperties, ObjectProvider restClientBuilderProvider, + List toolFunctionCallbacks, FunctionCallbackResolver functionCallbackResolver, + RetryTemplate retryTemplate, ResponseErrorHandler responseErrorHandler, + ObjectProvider observationRegistry, + ObjectProvider observationConvention) { + + var miniMaxApi = miniMaxApi(chatProperties.getBaseUrl(), commonProperties.getBaseUrl(), + chatProperties.getApiKey(), commonProperties.getApiKey(), + restClientBuilderProvider.getIfAvailable(RestClient::builder), responseErrorHandler); + + var chatModel = new MiniMaxChatModel(miniMaxApi, chatProperties.getOptions(), functionCallbackResolver, + toolFunctionCallbacks, retryTemplate, observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)); + + observationConvention.ifAvailable(chatModel::setObservationConvention); + return chatModel; + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = MiniMaxEmbeddingProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + public MiniMaxEmbeddingModel miniMaxEmbeddingModel(MiniMaxConnectionProperties commonProperties, + MiniMaxEmbeddingProperties embeddingProperties, + ObjectProvider restClientBuilderProvider, RetryTemplate retryTemplate, + ResponseErrorHandler responseErrorHandler, ObjectProvider observationRegistry, + ObjectProvider observationConvention) { + + var miniMaxApi = miniMaxApi(embeddingProperties.getBaseUrl(), commonProperties.getBaseUrl(), + embeddingProperties.getApiKey(), commonProperties.getApiKey(), + restClientBuilderProvider.getIfAvailable(RestClient::builder), responseErrorHandler); + + var embeddingModel = new MiniMaxEmbeddingModel(miniMaxApi, embeddingProperties.getMetadataMode(), + embeddingProperties.getOptions(), retryTemplate, + observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)); + + observationConvention.ifAvailable(embeddingModel::setObservationConvention); + + return embeddingModel; + } + + private MiniMaxApi miniMaxApi(String baseUrl, String commonBaseUrl, String apiKey, String commonApiKey, + RestClient.Builder restClientBuilder, ResponseErrorHandler responseErrorHandler) { + + String resolvedBaseUrl = StringUtils.hasText(baseUrl) ? baseUrl : commonBaseUrl; + Assert.hasText(resolvedBaseUrl, "MiniMax base URL must be set"); + + String resolvedApiKey = StringUtils.hasText(apiKey) ? apiKey : commonApiKey; + Assert.hasText(resolvedApiKey, "MiniMax API key must be set"); + + return new MiniMaxApi(resolvedBaseUrl, resolvedApiKey, restClientBuilder, responseErrorHandler); + } + + @Bean + @ConditionalOnMissingBean + public FunctionCallbackResolver springAiFunctionManager(ApplicationContext context) { + DefaultFunctionCallbackResolver manager = new DefaultFunctionCallbackResolver(); + manager.setApplicationContext(context); + return manager; + } + +} diff --git a/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxChatProperties.java b/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxChatProperties.java new file mode 100644 index 00000000000..89c0e0258a0 --- /dev/null +++ b/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxChatProperties.java @@ -0,0 +1,65 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.minimax; + +import org.springframework.ai.minimax.MiniMaxChatOptions; +import org.springframework.ai.minimax.api.MiniMaxApi; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * Configuration properties for MiniMax chat model. + * + * @author Geng Rong + */ +@ConfigurationProperties(MiniMaxChatProperties.CONFIG_PREFIX) +public class MiniMaxChatProperties extends MiniMaxParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.minimax.chat"; + + public static final String DEFAULT_CHAT_MODEL = MiniMaxApi.ChatModel.ABAB_5_5_Chat.value; + + private static final Double DEFAULT_TEMPERATURE = 0.7; + + /** + * Enable MiniMax chat model. + */ + private boolean enabled = true; + + @NestedConfigurationProperty + private MiniMaxChatOptions options = MiniMaxChatOptions.builder() + .model(DEFAULT_CHAT_MODEL) + .temperature(DEFAULT_TEMPERATURE) + .build(); + + public MiniMaxChatOptions getOptions() { + return this.options; + } + + public void setOptions(MiniMaxChatOptions options) { + this.options = options; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + +} diff --git a/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxConnectionProperties.java b/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxConnectionProperties.java new file mode 100644 index 00000000000..59d5ff0cadf --- /dev/null +++ b/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxConnectionProperties.java @@ -0,0 +1,32 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.minimax; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(MiniMaxConnectionProperties.CONFIG_PREFIX) +public class MiniMaxConnectionProperties extends MiniMaxParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.minimax"; + + public static final String DEFAULT_BASE_URL = "https://api.minimax.chat"; + + public MiniMaxConnectionProperties() { + super.setBaseUrl(DEFAULT_BASE_URL); + } + +} diff --git a/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxEmbeddingProperties.java b/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxEmbeddingProperties.java new file mode 100644 index 00000000000..13293a71b77 --- /dev/null +++ b/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxEmbeddingProperties.java @@ -0,0 +1,71 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.minimax; + +import org.springframework.ai.document.MetadataMode; +import org.springframework.ai.minimax.MiniMaxEmbeddingOptions; +import org.springframework.ai.minimax.api.MiniMaxApi; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * Configuration properties for MiniMax embedding model. + * + * @author Geng Rong + */ +@ConfigurationProperties(MiniMaxEmbeddingProperties.CONFIG_PREFIX) +public class MiniMaxEmbeddingProperties extends MiniMaxParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.minimax.embedding"; + + public static final String DEFAULT_EMBEDDING_MODEL = MiniMaxApi.EmbeddingModel.Embo_01.value; + + /** + * Enable MiniMax embedding model. + */ + private boolean enabled = true; + + private MetadataMode metadataMode = MetadataMode.EMBED; + + @NestedConfigurationProperty + private MiniMaxEmbeddingOptions options = MiniMaxEmbeddingOptions.builder().model(DEFAULT_EMBEDDING_MODEL).build(); + + public MiniMaxEmbeddingOptions getOptions() { + return this.options; + } + + public void setOptions(MiniMaxEmbeddingOptions options) { + this.options = options; + } + + public MetadataMode getMetadataMode() { + return this.metadataMode; + } + + public void setMetadataMode(MetadataMode metadataMode) { + this.metadataMode = metadataMode; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + +} diff --git a/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxParentProperties.java b/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxParentProperties.java new file mode 100644 index 00000000000..34b98c0c122 --- /dev/null +++ b/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxParentProperties.java @@ -0,0 +1,44 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.minimax; + +/** + * @author Geng Rong + */ +class MiniMaxParentProperties { + + 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-minimax-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..7eadaf1d2ad --- /dev/null +++ b/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/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.autoconfigure.minimax.MiniMaxAutoConfiguration diff --git a/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/FunctionCallbackInPromptIT.java b/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/FunctionCallbackInPromptIT.java new file mode 100644 index 00000000000..5e9c88ad9ff --- /dev/null +++ b/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/FunctionCallbackInPromptIT.java @@ -0,0 +1,117 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.minimax; + +import java.util.List; +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.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.chat.messages.AssistantMessage; +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.Prompt; +import org.springframework.ai.minimax.MiniMaxChatModel; +import org.springframework.ai.minimax.MiniMaxChatOptions; +import org.springframework.ai.model.function.FunctionCallback; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Geng Rong + */ +@EnabledIfEnvironmentVariable(named = "MINIMAX_API_KEY", matches = ".*") +public class FunctionCallbackInPromptIT { + + private final Logger logger = LoggerFactory.getLogger(FunctionCallbackInPromptIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.minimax.apiKey=" + System.getenv("MINIMAX_API_KEY")) + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MiniMaxAutoConfiguration.class)); + + @Test + void functionCallTest() { + this.contextRunner.withPropertyValues("spring.ai.minimax.chat.options.model=abab6.5s-chat").run(context -> { + + MiniMaxChatModel chatModel = context.getBean(MiniMaxChatModel.class); + + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius."); + + var promptOptions = MiniMaxChatOptions.builder() + .functionCallbacks(List.of(FunctionCallback.builder() + .function("CurrentWeatherService", new MockWeatherService()) + .description("Get the weather in location") + .inputType(MockWeatherService.Request.class) + .build())) + .build(); + + ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), promptOptions)); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + }); + } + + @Test + void streamingFunctionCallTest() { + + this.contextRunner.withPropertyValues("spring.ai.minimax.chat.options.model=abab6.5s-chat").run(context -> { + + MiniMaxChatModel chatModel = context.getBean(MiniMaxChatModel.class); + + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius."); + + var promptOptions = MiniMaxChatOptions.builder() + .functionCallbacks(List.of(FunctionCallback.builder() + .function("CurrentWeatherService", new MockWeatherService()) + .description("Get the weather in location") + .inputType(MockWeatherService.Request.class) + .build())) + .build(); + + Flux response = chatModel.stream(new Prompt(List.of(userMessage), 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("30.0", "30"); + assertThat(content).containsAnyOf("10.0", "10"); + assertThat(content).containsAnyOf("15.0", "15"); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/FunctionCallbackWithPlainFunctionBeanIT.java b/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/FunctionCallbackWithPlainFunctionBeanIT.java new file mode 100644 index 00000000000..bedb41f5d7b --- /dev/null +++ b/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/FunctionCallbackWithPlainFunctionBeanIT.java @@ -0,0 +1,177 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.minimax; + +import java.util.List; +import java.util.function.Function; +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.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.chat.messages.AssistantMessage; +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.Prompt; +import org.springframework.ai.minimax.MiniMaxChatModel; +import org.springframework.ai.minimax.MiniMaxChatOptions; +import org.springframework.ai.model.function.FunctionCallingOptions; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Description; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Geng Rong + */ +@EnabledIfEnvironmentVariable(named = "MINIMAX_API_KEY", matches = ".*") +class FunctionCallbackWithPlainFunctionBeanIT { + + private final Logger logger = LoggerFactory.getLogger(FunctionCallbackWithPlainFunctionBeanIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.minimax.apiKey=" + System.getenv("MINIMAX_API_KEY")) + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MiniMaxAutoConfiguration.class)) + .withUserConfiguration(Config.class); + + // FIXME: multiple function calls may stop prematurely due to model performance + @Test + void functionCallTest() { + this.contextRunner.withPropertyValues("spring.ai.minimax.chat.options.model=abab6.5s-chat").run(context -> { + + MiniMaxChatModel chatModel = context.getBean(MiniMaxChatModel.class); + + // Test weatherFunction + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius."); + + ChatResponse response = chatModel.call( + new Prompt(List.of(userMessage), MiniMaxChatOptions.builder().function("weatherFunction").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + + // Test weatherFunctionTwo + response = chatModel.call(new Prompt(List.of(userMessage), + MiniMaxChatOptions.builder().function("weatherFunctionTwo").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + + }); + } + + @Test + void functionCallWithPortableFunctionCallingOptions() { + this.contextRunner.withPropertyValues("spring.ai.minimax.chat.options.model=abab6.5s-chat").run(context -> { + + MiniMaxChatModel chatModel = context.getBean(MiniMaxChatModel.class); + + // Test weatherFunction + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius."); + + FunctionCallingOptions functionOptions = FunctionCallingOptions.builder() + .function("weatherFunction") + .build(); + + ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), functionOptions)); + + logger.info("Response: {}", response); + }); + } + + // FIXME: multiple function calls may stop prematurely due to model performance + @Test + void streamFunctionCallTest() { + this.contextRunner.withPropertyValues("spring.ai.minimax.chat.options.model=abab6.5s-chat").run(context -> { + + MiniMaxChatModel chatModel = context.getBean(MiniMaxChatModel.class); + + // Test weatherFunction + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius."); + + Flux response = chatModel.stream( + new Prompt(List.of(userMessage), MiniMaxChatOptions.builder().function("weatherFunction").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("30.0", "30"); + assertThat(content).containsAnyOf("10.0", "10"); + assertThat(content).containsAnyOf("15.0", "15"); + + // Test weatherFunctionTwo + response = chatModel.stream(new Prompt(List.of(userMessage), + MiniMaxChatOptions.builder().function("weatherFunctionTwo").build())); + + 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("30.0", "30"); + assertThat(content).containsAnyOf("10.0", "10"); + assertThat(content).containsAnyOf("15.0", "15"); + }); + } + + @Configuration + static class Config { + + @Bean + @Description("Get the weather in location") + public Function weatherFunction() { + return new MockWeatherService(); + } + + // Relies on the Request's JsonClassDescription annotation to provide the + // function description. + @Bean + public Function weatherFunctionTwo() { + MockWeatherService weatherService = new MockWeatherService(); + return (weatherService::apply); + } + + } + +} diff --git a/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MiniMaxAutoConfigurationIT.java b/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MiniMaxAutoConfigurationIT.java new file mode 100644 index 00000000000..6a3893274a6 --- /dev/null +++ b/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MiniMaxAutoConfigurationIT.java @@ -0,0 +1,97 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.minimax; + +import java.util.List; +import java.util.stream.Collectors; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import reactor.core.publisher.Flux; + +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.ai.minimax.MiniMaxChatModel; +import org.springframework.ai.minimax.MiniMaxEmbeddingModel; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Geng Rong + */ +@EnabledIfEnvironmentVariable(named = "MINIMAX_API_KEY", matches = ".*") +public class MiniMaxAutoConfigurationIT { + + private static final Log logger = LogFactory.getLog(MiniMaxAutoConfigurationIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.minimax.apiKey=" + System.getenv("MINIMAX_API_KEY")) + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MiniMaxAutoConfiguration.class)); + + @Test + void generate() { + this.contextRunner.run(context -> { + MiniMaxChatModel chatModel = context.getBean(MiniMaxChatModel.class); + String response = chatModel.call("Hello"); + assertThat(response).isNotEmpty(); + logger.info("Response: " + response); + }); + } + + @Test + void generateStreaming() { + this.contextRunner.run(context -> { + MiniMaxChatModel chatModel = context.getBean(MiniMaxChatModel.class); + Flux responseFlux = chatModel.stream(new Prompt(new UserMessage("Hello"))); + String response = responseFlux.collectList() + .block() + .stream() + .map(chatResponse -> chatResponse.getResults().get(0).getOutput().getText()) + .collect(Collectors.joining()); + + assertThat(response).isNotEmpty(); + logger.info("Response: " + response); + }); + } + + @Test + void embedding() { + this.contextRunner.run(context -> { + MiniMaxEmbeddingModel embeddingModel = context.getBean(MiniMaxEmbeddingModel.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-minimax-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MiniMaxFunctionCallbackIT.java b/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MiniMaxFunctionCallbackIT.java new file mode 100644 index 00000000000..c3937895cdf --- /dev/null +++ b/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MiniMaxFunctionCallbackIT.java @@ -0,0 +1,122 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.minimax; + +import java.util.List; +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.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.chat.messages.AssistantMessage; +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.Prompt; +import org.springframework.ai.minimax.MiniMaxChatModel; +import org.springframework.ai.minimax.MiniMaxChatOptions; +import org.springframework.ai.model.function.FunctionCallback; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Geng Rong + */ +@EnabledIfEnvironmentVariable(named = "MINIMAX_API_KEY", matches = ".*") +public class MiniMaxFunctionCallbackIT { + + private final Logger logger = LoggerFactory.getLogger(MiniMaxFunctionCallbackIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.minimax.apiKey=" + System.getenv("MINIMAX_API_KEY")) + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MiniMaxAutoConfiguration.class)) + .withUserConfiguration(Config.class); + + @Test + void functionCallTest() { + this.contextRunner.withPropertyValues("spring.ai.minimax.chat.options.model=abab6.5s-chat").run(context -> { + + MiniMaxChatModel chatModel = context.getBean(MiniMaxChatModel.class); + + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius."); + + ChatResponse response = chatModel + .call(new Prompt(List.of(userMessage), MiniMaxChatOptions.builder().function("WeatherInfo").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + + }); + } + + @Test + void streamFunctionCallTest() { + this.contextRunner.withPropertyValues("spring.ai.minimax.chat.options.model=abab6.5s-chat").run(context -> { + + MiniMaxChatModel chatModel = context.getBean(MiniMaxChatModel.class); + + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius."); + + Flux response = chatModel + .stream(new Prompt(List.of(userMessage), MiniMaxChatOptions.builder().function("WeatherInfo").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("30.0", "30"); + assertThat(content).containsAnyOf("10.0", "10"); + assertThat(content).containsAnyOf("15.0", "15"); + + }); + } + + @Configuration + static class Config { + + @Bean + public FunctionCallback weatherFunctionInfo() { + + return FunctionCallback.builder() + .function("WeatherInfo", new MockWeatherService()) + .description("Get the weather in location") + .inputType(MockWeatherService.Request.class) + .build(); + } + + } + +} diff --git a/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MiniMaxPropertiesTests.java b/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MiniMaxPropertiesTests.java new file mode 100644 index 00000000000..4140f029b4b --- /dev/null +++ b/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MiniMaxPropertiesTests.java @@ -0,0 +1,331 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.minimax; + +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; +import org.skyscreamer.jsonassert.JSONCompareMode; + +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.minimax.MiniMaxChatModel; +import org.springframework.ai.minimax.MiniMaxEmbeddingModel; +import org.springframework.ai.minimax.api.MiniMaxApi; +import org.springframework.ai.model.ModelOptionsUtils; +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 org.springframework.ai.autoconfigure.minimax.MiniMaxConnectionProperties}, + * {@link org.springframework.ai.autoconfigure.minimax.MiniMaxChatProperties} and + * {@link org.springframework.ai.autoconfigure.minimax.MiniMaxEmbeddingProperties}. + * + * @author Geng Rong + */ +public class MiniMaxPropertiesTests { + + @Test + public void chatProperties() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.minimax.base-url=TEST_BASE_URL", + "spring.ai.minimax.api-key=abc123", + "spring.ai.minimax.chat.options.model=MODEL_XYZ", + "spring.ai.minimax.chat.options.temperature=0.55") + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MiniMaxAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(MiniMaxChatProperties.class); + var connectionProperties = context.getBean(MiniMaxConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(chatProperties.getApiKey()).isNull(); + assertThat(chatProperties.getBaseUrl()).isNull(); + + assertThat(chatProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55); + }); + } + + @Test + public void chatOverrideConnectionProperties() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.minimax.base-url=TEST_BASE_URL", + "spring.ai.minimax.api-key=abc123", + "spring.ai.minimax.chat.base-url=TEST_BASE_URL2", + "spring.ai.minimax.chat.api-key=456", + "spring.ai.minimax.chat.options.model=MODEL_XYZ", + "spring.ai.minimax.chat.options.temperature=0.55") + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MiniMaxAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(MiniMaxChatProperties.class); + var connectionProperties = context.getBean(MiniMaxConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(chatProperties.getApiKey()).isEqualTo("456"); + assertThat(chatProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL2"); + + assertThat(chatProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55); + }); + } + + @Test + public void embeddingProperties() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.minimax.base-url=TEST_BASE_URL", + "spring.ai.minimax.api-key=abc123", + "spring.ai.minimax.embedding.options.model=MODEL_XYZ") + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MiniMaxAutoConfiguration.class)) + .run(context -> { + var embeddingProperties = context.getBean(MiniMaxEmbeddingProperties.class); + var connectionProperties = context.getBean(MiniMaxConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(embeddingProperties.getApiKey()).isNull(); + assertThat(embeddingProperties.getBaseUrl()).isNull(); + + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + }); + } + + @Test + public void embeddingOverrideConnectionProperties() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.minimax.base-url=TEST_BASE_URL", + "spring.ai.minimax.api-key=abc123", + "spring.ai.minimax.embedding.base-url=TEST_BASE_URL2", + "spring.ai.minimax.embedding.api-key=456", + "spring.ai.minimax.embedding.options.model=MODEL_XYZ") + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MiniMaxAutoConfiguration.class)) + .run(context -> { + var embeddingProperties = context.getBean(MiniMaxEmbeddingProperties.class); + var connectionProperties = context.getBean(MiniMaxConnectionProperties.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 chatOptionsTest() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.minimax.api-key=API_KEY", + "spring.ai.minimax.base-url=TEST_BASE_URL", + + "spring.ai.minimax.chat.options.model=MODEL_XYZ", + "spring.ai.minimax.chat.options.frequencyPenalty=-1.5", + "spring.ai.minimax.chat.options.logitBias.myTokenId=-5", + "spring.ai.minimax.chat.options.maxTokens=123", + "spring.ai.minimax.chat.options.n=10", + "spring.ai.minimax.chat.options.presencePenalty=0", + "spring.ai.minimax.chat.options.responseFormat.type=json", + "spring.ai.minimax.chat.options.seed=66", + "spring.ai.minimax.chat.options.stop=boza,koza", + "spring.ai.minimax.chat.options.temperature=0.55", + "spring.ai.minimax.chat.options.topP=0.56", + + // "spring.ai.minimax.chat.options.toolChoice.functionName=toolChoiceFunctionName", + "spring.ai.minimax.chat.options.toolChoice=" + ModelOptionsUtils.toJsonString(MiniMaxApi.ChatCompletionRequest.ToolChoiceBuilder.function("toolChoiceFunctionName")), + + "spring.ai.minimax.chat.options.tools[0].function.name=myFunction1", + "spring.ai.minimax.chat.options.tools[0].function.description=function description", + "spring.ai.minimax.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"] + } + """ + ) + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MiniMaxAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(MiniMaxChatProperties.class); + var connectionProperties = context.getBean(MiniMaxConnectionProperties.class); + var embeddingProperties = context.getBean(MiniMaxEmbeddingProperties.class); + + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY"); + + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("embo-01"); + + assertThat(chatProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(chatProperties.getOptions().getFrequencyPenalty()).isEqualTo(-1.5); + assertThat(chatProperties.getOptions().getMaxTokens()).isEqualTo(123); + assertThat(chatProperties.getOptions().getN()).isEqualTo(10); + assertThat(chatProperties.getOptions().getPresencePenalty()).isEqualTo(0); + assertThat(chatProperties.getOptions().getResponseFormat()) + .isEqualTo(new MiniMaxApi.ChatCompletionRequest.ResponseFormat("json")); + assertThat(chatProperties.getOptions().getSeed()).isEqualTo(66); + assertThat(chatProperties.getOptions().getStop()).contains("boza", "koza"); + assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55); + assertThat(chatProperties.getOptions().getTopP()).isEqualTo(0.56); + + JSONAssert.assertEquals("{\"type\":\"function\",\"function\":{\"name\":\"toolChoiceFunctionName\"}}", + chatProperties.getOptions().getToolChoice(), JSONCompareMode.LENIENT); + + assertThat(chatProperties.getOptions().getTools()).hasSize(1); + var tool = chatProperties.getOptions().getTools().get(0); + assertThat(tool.getType()).isEqualTo(MiniMaxApi.FunctionTool.Type.FUNCTION); + var function = tool.getFunction(); + assertThat(function.getName()).isEqualTo("myFunction1"); + assertThat(function.getDescription()).isEqualTo("function description"); + assertThat(function.getParameters()).isNotEmpty(); + }); + } + + @Test + public void embeddingOptionsTest() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.minimax.api-key=API_KEY", + "spring.ai.minimax.base-url=TEST_BASE_URL", + + "spring.ai.minimax.embedding.options.model=MODEL_XYZ", + "spring.ai.minimax.embedding.options.encodingFormat=MyEncodingFormat" + ) + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MiniMaxAutoConfiguration.class)) + .run(context -> { + var connectionProperties = context.getBean(MiniMaxConnectionProperties.class); + var embeddingProperties = context.getBean(MiniMaxEmbeddingProperties.class); + + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY"); + + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + }); + } + + @Test + void embeddingActivation() { + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.minimax.api-key=API_KEY", "spring.ai.minimax.base-url=TEST_BASE_URL", + "spring.ai.minimax.embedding.enabled=false") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MiniMaxAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(MiniMaxEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(MiniMaxEmbeddingModel.class)).isEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.minimax.api-key=API_KEY", "spring.ai.minimax.base-url=TEST_BASE_URL") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MiniMaxAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(MiniMaxEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(MiniMaxEmbeddingModel.class)).isNotEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.minimax.api-key=API_KEY", "spring.ai.minimax.base-url=TEST_BASE_URL", + "spring.ai.minimax.embedding.enabled=true") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MiniMaxAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(MiniMaxEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(MiniMaxEmbeddingModel.class)).isNotEmpty(); + }); + } + + @Test + void chatActivation() { + new ApplicationContextRunner() + .withPropertyValues("spring.ai.minimax.api-key=API_KEY", "spring.ai.minimax.base-url=TEST_BASE_URL", + "spring.ai.minimax.chat.enabled=false") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MiniMaxAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(MiniMaxChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(MiniMaxChatModel.class)).isEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.minimax.api-key=API_KEY", "spring.ai.minimax.base-url=TEST_BASE_URL") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MiniMaxAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(MiniMaxChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(MiniMaxChatModel.class)).isNotEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.minimax.api-key=API_KEY", "spring.ai.minimax.base-url=TEST_BASE_URL", + "spring.ai.minimax.chat.enabled=true") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MiniMaxAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(MiniMaxChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(MiniMaxChatModel.class)).isNotEmpty(); + }); + + } + +} diff --git a/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MockWeatherService.java b/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MockWeatherService.java new file mode 100644 index 00000000000..bb0ef923c79 --- /dev/null +++ b/auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MockWeatherService.java @@ -0,0 +1,97 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.minimax; + +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; + +/** + * Mock 3rd party weather service. + * + * @author Geng Rong + */ +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("Get the weather in location") + public record Request(@JsonProperty(required = true, + value = "location") @JsonPropertyDescription("The city and state e.g. San Francisco, CA") String location, + @JsonProperty(required = true, value = "lat") @JsonPropertyDescription("The city latitude") double lat, + @JsonProperty(required = true, value = "lon") @JsonPropertyDescription("The city longitude") double lon, + @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/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/pom.xml b/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/pom.xml new file mode 100644 index 00000000000..a71f61edfcb --- /dev/null +++ b/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/pom.xml @@ -0,0 +1,107 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../../pom.xml + + spring-ai-mistral-ai-spring-boot-autoconfigure + jar + Spring AI Mistral Auto Configuration + Spring AI Mistral 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-mistral-ai + ${project.parent.version} + true + + + + + + org.springframework.ai + spring-ai-chat-client-spring-boot-autoconfigure + ${project.parent.version} + true + + + + org.springframework.ai + spring-ai-chat-model-spring-boot-autoconfigure + ${project.parent.version} + true + + + + org.springframework.ai + spring-ai-retry-spring-boot-autoconfigure + ${project.parent.version} + true + + + + + org.springframework.boot + spring-boot-starter + true + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + + org.springframework.ai + spring-ai-test + ${project.parent.version} + test + + + + org.springframework.ai + spring-ai-openai-spring-boot-autoconfigure + ${project.parent.version} + test + + + + org.springframework.ai + spring-ai-openai + ${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-mistral-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiAutoConfiguration.java b/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiAutoConfiguration.java new file mode 100644 index 00000000000..712756504fe --- /dev/null +++ b/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiAutoConfiguration.java @@ -0,0 +1,134 @@ +/* + * 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.autoconfigure.mistralai; + +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.ai.autoconfigure.chat.model.ToolCallingAutoConfiguration; +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.chat.observation.ChatModelObservationConvention; +import org.springframework.ai.embedding.observation.EmbeddingModelObservationConvention; +import org.springframework.ai.mistralai.MistralAiChatModel; +import org.springframework.ai.mistralai.MistralAiEmbeddingModel; +import org.springframework.ai.mistralai.api.MistralAiApi; +import org.springframework.ai.model.function.DefaultFunctionCallbackResolver; +import org.springframework.ai.model.function.FunctionCallbackResolver; +import org.springframework.ai.model.tool.ToolCallingManager; +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.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.web.client.ResponseErrorHandler; +import org.springframework.web.client.RestClient; + +/** + * {@link AutoConfiguration Auto-configuration} for Mistral AI. + * + * @author Ricken Bazolo + * @author Christian Tzolov + * @author Thomas Vitale + * @since 0.8.1 + */ +@AutoConfiguration(after = { RestClientAutoConfiguration.class, SpringAiRetryAutoConfiguration.class, + ToolCallingAutoConfiguration.class }) +@EnableConfigurationProperties({ MistralAiEmbeddingProperties.class, MistralAiCommonProperties.class, + MistralAiChatProperties.class }) +@ConditionalOnClass(MistralAiApi.class) +@ImportAutoConfiguration(classes = { SpringAiRetryAutoConfiguration.class, RestClientAutoConfiguration.class, + ToolCallingAutoConfiguration.class }) +public class MistralAiAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = MistralAiEmbeddingProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + public MistralAiEmbeddingModel mistralAiEmbeddingModel(MistralAiCommonProperties commonProperties, + MistralAiEmbeddingProperties embeddingProperties, + ObjectProvider restClientBuilderProvider, RetryTemplate retryTemplate, + ResponseErrorHandler responseErrorHandler, ObjectProvider observationRegistry, + ObjectProvider observationConvention) { + + var mistralAiApi = mistralAiApi(embeddingProperties.getApiKey(), commonProperties.getApiKey(), + embeddingProperties.getBaseUrl(), commonProperties.getBaseUrl(), + restClientBuilderProvider.getIfAvailable(RestClient::builder), responseErrorHandler); + + var embeddingModel = new MistralAiEmbeddingModel(mistralAiApi, embeddingProperties.getMetadataMode(), + embeddingProperties.getOptions(), retryTemplate, + observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)); + + observationConvention.ifAvailable(embeddingModel::setObservationConvention); + + return embeddingModel; + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = MistralAiChatProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + public MistralAiChatModel mistralAiChatModel(MistralAiCommonProperties commonProperties, + MistralAiChatProperties chatProperties, ObjectProvider restClientBuilderProvider, + ToolCallingManager toolCallingManager, RetryTemplate retryTemplate, + ResponseErrorHandler responseErrorHandler, ObjectProvider observationRegistry, + ObjectProvider observationConvention) { + + var mistralAiApi = mistralAiApi(chatProperties.getApiKey(), commonProperties.getApiKey(), + chatProperties.getBaseUrl(), commonProperties.getBaseUrl(), + restClientBuilderProvider.getIfAvailable(RestClient::builder), responseErrorHandler); + + var chatModel = MistralAiChatModel.builder() + .mistralAiApi(mistralAiApi) + .defaultOptions(chatProperties.getOptions()) + .toolCallingManager(toolCallingManager) + .retryTemplate(retryTemplate) + .observationRegistry(observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)) + .build(); + + observationConvention.ifAvailable(chatModel::setObservationConvention); + + return chatModel; + } + + private MistralAiApi mistralAiApi(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, "Mistral API key must be set"); + Assert.hasText(resoledBaseUrl, "Mistral base URL must be set"); + + return new MistralAiApi(resoledBaseUrl, resolvedApiKey, restClientBuilder, responseErrorHandler); + } + + @Bean + @ConditionalOnMissingBean + public FunctionCallbackResolver springAiFunctionManager(ApplicationContext context) { + DefaultFunctionCallbackResolver manager = new DefaultFunctionCallbackResolver(); + manager.setApplicationContext(context); + return manager; + } + +} diff --git a/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiChatProperties.java b/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiChatProperties.java new file mode 100644 index 00000000000..6047f804e3b --- /dev/null +++ b/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiChatProperties.java @@ -0,0 +1,79 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.mistralai; + +import org.springframework.ai.mistralai.MistralAiChatOptions; +import org.springframework.ai.mistralai.api.MistralAiApi; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * Configuration properties for Mistral AI chat. + * + * @author Ricken Bazolo + * @author Christian Tzolov + * @author Thomas Vitale + * @author Alexandros Pappas + * @since 0.8.1 + */ +@ConfigurationProperties(MistralAiChatProperties.CONFIG_PREFIX) +public class MistralAiChatProperties extends MistralAiParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.mistralai.chat"; + + public static final String DEFAULT_CHAT_MODEL = MistralAiApi.ChatModel.OPEN_MISTRAL_7B.getValue(); + + private static final Double DEFAULT_TEMPERATURE = 0.7; + + private static final Double DEFAULT_TOP_P = 1.0; + + private static final Boolean IS_ENABLED = false; + + /** + * Enable OpenAI chat model. + */ + private boolean enabled = true; + + @NestedConfigurationProperty + private MistralAiChatOptions options = MistralAiChatOptions.builder() + .model(DEFAULT_CHAT_MODEL) + .temperature(DEFAULT_TEMPERATURE) + .safePrompt(!IS_ENABLED) + .topP(DEFAULT_TOP_P) + .build(); + + public MistralAiChatProperties() { + super.setBaseUrl(MistralAiCommonProperties.DEFAULT_BASE_URL); + } + + public MistralAiChatOptions getOptions() { + return this.options; + } + + public void setOptions(MistralAiChatOptions options) { + this.options = options; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + +} diff --git a/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiCommonProperties.java b/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiCommonProperties.java new file mode 100644 index 00000000000..22d0efada20 --- /dev/null +++ b/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiCommonProperties.java @@ -0,0 +1,39 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.mistralai; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Common properties for Mistral AI. + * + * @author Ricken Bazolo + * @author Christian Tzolov + * @since 0.8.1 + */ +@ConfigurationProperties(MistralAiCommonProperties.CONFIG_PREFIX) +public class MistralAiCommonProperties extends MistralAiParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.mistralai"; + + public static final String DEFAULT_BASE_URL = "https://api.mistral.ai"; + + public MistralAiCommonProperties() { + super.setBaseUrl(DEFAULT_BASE_URL); + } + +} diff --git a/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiEmbeddingProperties.java b/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiEmbeddingProperties.java new file mode 100644 index 00000000000..380a320ffbb --- /dev/null +++ b/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiEmbeddingProperties.java @@ -0,0 +1,81 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.mistralai; + +import org.springframework.ai.document.MetadataMode; +import org.springframework.ai.mistralai.MistralAiEmbeddingOptions; +import org.springframework.ai.mistralai.api.MistralAiApi; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * Configuration properties for MistralAI embedding model. + * + * @author Ricken Bazolo + * @since 0.8.1 + */ +@ConfigurationProperties(MistralAiEmbeddingProperties.CONFIG_PREFIX) +public class MistralAiEmbeddingProperties extends MistralAiParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.mistralai.embedding"; + + public static final String DEFAULT_EMBEDDING_MODEL = MistralAiApi.EmbeddingModel.EMBED.getValue(); + + public static final String DEFAULT_ENCODING_FORMAT = "float"; + + public MetadataMode metadataMode = MetadataMode.EMBED; + + /** + * Enable MistralAI embedding model. + */ + private boolean enabled = true; + + @NestedConfigurationProperty + private MistralAiEmbeddingOptions options = MistralAiEmbeddingOptions.builder() + .withModel(DEFAULT_EMBEDDING_MODEL) + .withEncodingFormat(DEFAULT_ENCODING_FORMAT) + .build(); + + public MistralAiEmbeddingProperties() { + super.setBaseUrl(MistralAiCommonProperties.DEFAULT_BASE_URL); + } + + public MistralAiEmbeddingOptions getOptions() { + return this.options; + } + + public void setOptions(MistralAiEmbeddingOptions options) { + this.options = options; + } + + public MetadataMode getMetadataMode() { + return this.metadataMode; + } + + public void setMetadataMode(MetadataMode metadataMode) { + this.metadataMode = metadataMode; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + +} diff --git a/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiParentProperties.java b/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiParentProperties.java new file mode 100644 index 00000000000..c44b10340d2 --- /dev/null +++ b/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiParentProperties.java @@ -0,0 +1,47 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.mistralai; + +/** + * Parent properties for Mistral AI. + * + * @author Ricken Bazolo + * @since 0.8.1 + */ +public class MistralAiParentProperties { + + private String apiKey; + + private String baseUrl; + + public String getApiKey() { + return this.apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getBaseUrl() { + return this.baseUrl; + } + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + +} diff --git a/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 00000000000..97efb082ca6 --- /dev/null +++ b/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,11 @@ +{ + "groups": [ + { + "name": "spring.ai.mistralai.chat.options.tool-choice", + "type": "org.springframework.ai.mistralai.api.MistralAiApi$ChatCompletionRequest$ToolChoice", + "sourceType": "org.springframework.ai.mistralai.MistralAiChatOptions" + } + ], + "properties": [], + "hints": [] +} diff --git a/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..f6198ab3697 --- /dev/null +++ b/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/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.autoconfigure.mistralai.MistralAiAutoConfiguration diff --git a/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/MistralAiAutoConfigurationIT.java b/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/MistralAiAutoConfigurationIT.java new file mode 100644 index 00000000000..99d91c6f3b0 --- /dev/null +++ b/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/MistralAiAutoConfigurationIT.java @@ -0,0 +1,95 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.mistralai; + +import java.util.List; +import java.util.stream.Collectors; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import reactor.core.publisher.Flux; + +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.ai.mistralai.MistralAiChatModel; +import org.springframework.ai.mistralai.MistralAiEmbeddingModel; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Christian Tzolov + * @since 0.8.1 + */ +@EnabledIfEnvironmentVariable(named = "MISTRAL_AI_API_KEY", matches = ".*") +public class MistralAiAutoConfigurationIT { + + private static final Log logger = LogFactory.getLog(MistralAiAutoConfigurationIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.mistralai.apiKey=" + System.getenv("MISTRAL_AI_API_KEY")) + .withConfiguration(AutoConfigurations.of(MistralAiAutoConfiguration.class)); + + @Test + void generate() { + this.contextRunner.run(context -> { + MistralAiChatModel chatModel = context.getBean(MistralAiChatModel.class); + String response = chatModel.call("Hello"); + assertThat(response).isNotEmpty(); + logger.info("Response: " + response); + }); + } + + @Test + void generateStreaming() { + this.contextRunner.run(context -> { + MistralAiChatModel chatModel = context.getBean(MistralAiChatModel.class); + Flux responseFlux = chatModel.stream(new Prompt(new UserMessage("Hello"))); + String response = responseFlux.collectList() + .block() + .stream() + .map(chatResponse -> chatResponse.getResults().get(0).getOutput().getText()) + .collect(Collectors.joining()); + + assertThat(response).isNotEmpty(); + logger.info("Response: " + response); + }); + } + + @Test + void embedding() { + this.contextRunner.run(context -> { + MistralAiEmbeddingModel embeddingModel = context.getBean(MistralAiEmbeddingModel.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(1024); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/MistralAiPropertiesTests.java b/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/MistralAiPropertiesTests.java new file mode 100644 index 00000000000..46c814bc380 --- /dev/null +++ b/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/MistralAiPropertiesTests.java @@ -0,0 +1,148 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.mistralai; + +import org.junit.jupiter.api.Test; + +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.mistralai.api.MistralAiApi; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit Tests for {@link MistralAiCommonProperties}, {@link MistralAiEmbeddingProperties}. + */ +public class MistralAiPropertiesTests { + + @Test + public void embeddingProperties() { + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.mistralai.base-url=TEST_BASE_URL", "spring.ai.mistralai.api-key=abc123", + "spring.ai.mistralai.embedding.options.model=MODEL_XYZ") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MistralAiAutoConfiguration.class)) + .run(context -> { + var embeddingProperties = context.getBean(MistralAiEmbeddingProperties.class); + var connectionProperties = context.getBean(MistralAiCommonProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(embeddingProperties.getApiKey()).isNull(); + assertThat(embeddingProperties.getBaseUrl()).isEqualTo(MistralAiCommonProperties.DEFAULT_BASE_URL); + + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + }); + } + + @Test + public void chatOptionsTest() { + + new ApplicationContextRunner().withPropertyValues("spring.ai.mistralai.base-url=TEST_BASE_URL", + "spring.ai.mistralai.chat.options.tools[0].function.name=myFunction1", + "spring.ai.mistralai.chat.options.tools[0].function.description=function description", + "spring.ai.mistralai.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"] + } + """, + + "spring.ai.mistralai.api-key=abc123", "spring.ai.mistralai.embedding.base-url=TEST_BASE_URL2", + "spring.ai.mistralai.embedding.api-key=456", "spring.ai.mistralai.embedding.options.model=MODEL_XYZ") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MistralAiAutoConfiguration.class)) + .run(context -> { + + var chatProperties = context.getBean(MistralAiChatProperties.class); + + var tool = chatProperties.getOptions().getTools().get(0); + assertThat(tool.getType()).isEqualTo(MistralAiApi.FunctionTool.Type.FUNCTION); + var function = tool.getFunction(); + assertThat(function.getName()).isEqualTo("myFunction1"); + assertThat(function.getDescription()).isEqualTo("function description"); + assertThat(function.getParameters()).isNotEmpty(); + }); + } + + @Test + public void embeddingOverrideConnectionProperties() { + + new ApplicationContextRunner().withPropertyValues("spring.ai.mistralai.base-url=TEST_BASE_URL", + "spring.ai.mistralai.api-key=abc123", "spring.ai.mistralai.embedding.base-url=TEST_BASE_URL2", + "spring.ai.mistralai.embedding.api-key=456", "spring.ai.mistralai.embedding.options.model=MODEL_XYZ") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MistralAiAutoConfiguration.class)) + .run(context -> { + var embeddingProperties = context.getBean(MistralAiEmbeddingProperties.class); + var connectionProperties = context.getBean(MistralAiCommonProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(embeddingProperties.getApiKey()).isEqualTo("456"); + assertThat(embeddingProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL2"); + + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + }); + } + + @Test + public void embeddingOptionsTest() { + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.mistralai.api-key=API_KEY", "spring.ai.mistralai.base-url=TEST_BASE_URL", + + "spring.ai.mistralai.embedding.options.model=MODEL_XYZ", + "spring.ai.mistralai.embedding.options.encodingFormat=MyEncodingFormat") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MistralAiAutoConfiguration.class)) + .run(context -> { + var connectionProperties = context.getBean(MistralAiCommonProperties.class); + var embeddingProperties = context.getBean(MistralAiEmbeddingProperties.class); + + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY"); + + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(embeddingProperties.getOptions().getEncodingFormat()).isEqualTo("MyEncodingFormat"); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/tool/PaymentStatusBeanIT.java b/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/tool/PaymentStatusBeanIT.java new file mode 100644 index 00000000000..36f7c8b3880 --- /dev/null +++ b/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/tool/PaymentStatusBeanIT.java @@ -0,0 +1,115 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.mistralai.tool; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import com.fasterxml.jackson.annotation.JsonProperty; +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.autoconfigure.mistralai.MistralAiAutoConfiguration; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.mistralai.MistralAiChatModel; +import org.springframework.ai.mistralai.MistralAiChatOptions; +import org.springframework.ai.mistralai.api.MistralAiApi; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Description; + +import static org.assertj.core.api.Assertions.assertThat; + +@EnabledIfEnvironmentVariable(named = "MISTRAL_AI_API_KEY", matches = ".*") +class PaymentStatusBeanIT { + + // 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")); + + private final Logger logger = LoggerFactory.getLogger(PaymentStatusBeanIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.mistralai.apiKey=" + System.getenv("MISTRAL_AI_API_KEY")) + .withConfiguration(AutoConfigurations.of(MistralAiAutoConfiguration.class)) + .withUserConfiguration(Config.class); + + @Test + void functionCallTest() { + + this.contextRunner + .withPropertyValues("spring.ai.mistralai.chat.options.model=" + MistralAiApi.ChatModel.LARGE.getValue()) + .run(context -> { + + MistralAiChatModel chatModel = context.getBean(MistralAiChatModel.class); + + ChatResponse response = chatModel + .call(new Prompt(List.of(new UserMessage("What's the status of my transaction with id T1001?")), + MistralAiChatOptions.builder() + .function("retrievePaymentStatus") + .function("retrievePaymentDate") + .build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).containsIgnoringCase("T1001"); + assertThat(response.getResult().getOutput().getText()).containsIgnoringCase("paid"); + }); + } + + record StatusDate(String status, String date) { + + } + + @Configuration + static class Config { + + @Bean + @Description("Get payment status of a transaction") + public Function retrievePaymentStatus() { + return transaction -> new Status(DATA.get(transaction.transactionId).status()); + } + + @Bean + @Description("Get payment date of a transaction") + public Function retrievePaymentDate() { + return transaction -> new Date(DATA.get(transaction.transactionId).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) { + + } + + } + +} diff --git a/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/tool/PaymentStatusBeanOpenAiIT.java b/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/tool/PaymentStatusBeanOpenAiIT.java new file mode 100644 index 00000000000..321d00f646b --- /dev/null +++ b/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/tool/PaymentStatusBeanOpenAiIT.java @@ -0,0 +1,122 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.mistralai.tool; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import com.fasterxml.jackson.annotation.JsonProperty; +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.autoconfigure.openai.OpenAiAutoConfiguration; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.mistralai.api.MistralAiApi; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Description; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Same test as {@link PaymentStatusBeanIT.java} but using {@link OpenAiChatModel} for + * Mistral AI Function Calling implementation. + * + * @author Christian Tzolov + */ +@EnabledIfEnvironmentVariable(named = "MISTRAL_AI_API_KEY", matches = ".*") +class PaymentStatusBeanOpenAiIT { + + // 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")); + + private final Logger logger = LoggerFactory.getLogger(PaymentStatusBeanIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.apiKey=" + System.getenv("MISTRAL_AI_API_KEY"), + "spring.ai.openai.chat.base-url=https://api.mistral.ai") + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .withUserConfiguration(Config.class); + + @Test + void functionCallTest() { + + this.contextRunner + .withPropertyValues("spring.ai.openai.chat.options.model=" + MistralAiApi.ChatModel.SMALL.getValue()) + .run(context -> { + + OpenAiChatModel chatModel = context.getBean(OpenAiChatModel.class); + + ChatResponse response = chatModel + .call(new Prompt(List.of(new UserMessage("What's the status of my transaction with id T1001?")), + OpenAiChatOptions.builder() + .function("retrievePaymentStatus") + .function("retrievePaymentDate") + .build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).containsIgnoringCase("T1001"); + assertThat(response.getResult().getOutput().getText()).containsIgnoringCase("paid"); + }); + } + + record StatusDate(String status, String date) { + + } + + @Configuration + static class Config { + + @Bean + @Description("Get payment status of a transaction") + public Function retrievePaymentStatus() { + return transaction -> new Status(DATA.get(transaction.transactionId).status()); + } + + @Bean + @Description("Get payment date of a transaction") + public Function retrievePaymentDate() { + return transaction -> new Date(DATA.get(transaction.transactionId).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) { + + } + + } + +} diff --git a/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/tool/PaymentStatusPromptIT.java b/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/tool/PaymentStatusPromptIT.java new file mode 100644 index 00000000000..a0a88984d72 --- /dev/null +++ b/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/tool/PaymentStatusPromptIT.java @@ -0,0 +1,96 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.mistralai.tool; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonProperty; +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.autoconfigure.mistralai.MistralAiAutoConfiguration; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.mistralai.MistralAiChatModel; +import org.springframework.ai.mistralai.MistralAiChatOptions; +import org.springframework.ai.mistralai.api.MistralAiApi; +import org.springframework.ai.tool.function.FunctionToolCallback; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +@EnabledIfEnvironmentVariable(named = "MISTRAL_AI_API_KEY", matches = ".*") +public class PaymentStatusPromptIT { + + // Assuming we have the following payment data. + public static final Map DATA = Map.of(new Transaction("T1001"), + new StatusDate("Paid", "2021-10-05"), new Transaction("T1002"), new StatusDate("Unpaid", "2021-10-06"), + new Transaction("T1003"), new StatusDate("Paid", "2021-10-07"), new Transaction("T1004"), + new StatusDate("Paid", "2021-10-05"), new Transaction("T1005"), new StatusDate("Pending", "2021-10-08")); + + private final Logger logger = LoggerFactory.getLogger(WeatherServicePromptIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.mistralai.apiKey=" + System.getenv("MISTRAL_AI_API_KEY")) + .withConfiguration(AutoConfigurations.of(MistralAiAutoConfiguration.class)); + + @Test + void functionCallTest() { + this.contextRunner + .withPropertyValues("spring.ai.mistralai.chat.options.model=" + MistralAiApi.ChatModel.SMALL.getValue()) + .run(context -> { + + MistralAiChatModel chatModel = context.getBean(MistralAiChatModel.class); + + UserMessage userMessage = new UserMessage("What's the status of my transaction with id T1001?"); + + var promptOptions = MistralAiChatOptions.builder() + .functionCallbacks(List.of(FunctionToolCallback + .builder("retrievePaymentStatus", + (Transaction transaction) -> new Status(DATA.get(transaction).status())) + .description("Get payment status of a transaction") + .inputType(Transaction.class) + .build())) + .build(); + + ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), promptOptions)); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).containsIgnoringCase("T1001"); + assertThat(response.getResult().getOutput().getText()).containsIgnoringCase("paid"); + }); + } + + public record Transaction(@JsonProperty(required = true, value = "transaction_id") String id) { + + } + + public record Status(@JsonProperty(required = true, value = "status") String status) { + + } + + record StatusDate(String status, String date) { + + } + +} diff --git a/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/tool/WeatherServicePromptIT.java b/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/tool/WeatherServicePromptIT.java new file mode 100644 index 00000000000..7c4e56a7d42 --- /dev/null +++ b/auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/tool/WeatherServicePromptIT.java @@ -0,0 +1,148 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.mistralai.tool; + +import java.util.List; +import java.util.function.Function; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; +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.autoconfigure.mistralai.MistralAiAutoConfiguration; +import org.springframework.ai.autoconfigure.mistralai.tool.WeatherServicePromptIT.MyWeatherService.Request; +import org.springframework.ai.autoconfigure.mistralai.tool.WeatherServicePromptIT.MyWeatherService.Response; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.mistralai.MistralAiChatModel; +import org.springframework.ai.mistralai.MistralAiChatOptions; +import org.springframework.ai.mistralai.api.MistralAiApi; +import org.springframework.ai.mistralai.api.MistralAiApi.ChatCompletionRequest.ToolChoice; +import org.springframework.ai.model.tool.ToolCallingChatOptions; +import org.springframework.ai.tool.function.FunctionToolCallback; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Christian Tzolov + * @author Alexandros Pappas + * @since 0.8.1 + */ +@EnabledIfEnvironmentVariable(named = "MISTRAL_AI_API_KEY", matches = ".*") +public class WeatherServicePromptIT { + + private final Logger logger = LoggerFactory.getLogger(WeatherServicePromptIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.mistralai.api-key=" + System.getenv("MISTRAL_AI_API_KEY")) + .withConfiguration(AutoConfigurations.of(MistralAiAutoConfiguration.class)); + + @Test + void promptFunctionCall() { + this.contextRunner + .withPropertyValues("spring.ai.mistralai.chat.options.model=" + MistralAiApi.ChatModel.LARGE.getValue()) + .run(context -> { + + MistralAiChatModel chatModel = context.getBean(MistralAiChatModel.class); + + UserMessage userMessage = new UserMessage("What's the weather like in Paris? Use Celsius."); + // UserMessage userMessage = new UserMessage("What's the weather like in + // San Francisco, Tokyo, and + // Paris?"); + + var promptOptions = MistralAiChatOptions.builder() + .toolChoice(ToolChoice.AUTO) + .functionCallbacks( + List.of(FunctionToolCallback.builder("CurrentWeatherService", new MyWeatherService()) + .description("Get the current weather in requested location") + .inputType(MyWeatherService.Request.class) + .build())) + .build(); + + ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), promptOptions)); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).containsAnyOf("15", "15.0"); + }); + } + + @Test + void functionCallWithPortableFunctionCallingOptions() { + this.contextRunner + .withPropertyValues("spring.ai.mistralai.chat.options.model=" + MistralAiApi.ChatModel.LARGE.getValue()) + .run(context -> { + + MistralAiChatModel chatModel = context.getBean(MistralAiChatModel.class); + + UserMessage userMessage = new UserMessage("What's the weather like in Paris? Use Celsius."); + + ToolCallingChatOptions functionOptions = ToolCallingChatOptions.builder() + .toolCallbacks(List.of(FunctionToolCallback.builder("CurrentWeatherService", new MyWeatherService()) + .description("Get the current weather in requested location") + .inputType(MyWeatherService.Request.class) + .build())) + + .build(); + + ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), functionOptions)); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).containsAnyOf("15", "15.0"); + }); + } + + public static class MyWeatherService implements Function { + + @Override + public Response apply(Request request) { + if (request.location().contains("Paris")) { + return new Response(15, request.unit()); + } + else if (request.location().contains("Tokyo")) { + return new Response(10, request.unit()); + } + else if (request.location().contains("San Francisco")) { + return new Response(30, request.unit()); + } + throw new IllegalArgumentException("Invalid request: " + request); + } + + // @formatter:off + public enum Unit { C, F } + + @JsonInclude(Include.NON_NULL) + public record Request( + @JsonProperty(required = true, value = "location") String location, + @JsonProperty(required = true, value = "unit") Unit unit) { } + // @formatter:on + + public record Response(double temperature, Unit unit) { + + } + + } + +} diff --git a/auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/pom.xml b/auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/pom.xml new file mode 100644 index 00000000000..fdca98beec6 --- /dev/null +++ b/auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/pom.xml @@ -0,0 +1,93 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../../pom.xml + + spring-ai-moonshot-spring-boot-autoconfigure + jar + Spring AI Moonshot AI Auto Configuration + Spring AI Moonshot AI 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-moonshot + ${project.parent.version} + true + + + + + + org.springframework.ai + spring-ai-chat-client-spring-boot-autoconfigure + ${project.parent.version} + true + + + + org.springframework.ai + spring-ai-chat-model-spring-boot-autoconfigure + ${project.parent.version} + true + + + + org.springframework.ai + spring-ai-retry-spring-boot-autoconfigure + ${project.parent.version} + true + + + + + org.springframework.boot + spring-boot-starter + true + + + + org.springframework.boot + spring-boot-configuration-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-moonshot-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/moonshot/MoonshotAutoConfiguration.java b/auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/moonshot/MoonshotAutoConfiguration.java new file mode 100644 index 00000000000..12cdc8cf25a --- /dev/null +++ b/auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/moonshot/MoonshotAutoConfiguration.java @@ -0,0 +1,97 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.moonshot; + +import java.util.List; + +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.chat.observation.ChatModelObservationConvention; +import org.springframework.ai.model.function.DefaultFunctionCallbackResolver; +import org.springframework.ai.model.function.FunctionCallback; +import org.springframework.ai.model.function.FunctionCallbackResolver; +import org.springframework.ai.moonshot.MoonshotChatModel; +import org.springframework.ai.moonshot.api.MoonshotApi; +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.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.web.client.ResponseErrorHandler; +import org.springframework.web.client.RestClient; + +/** + * {@link AutoConfiguration Auto-configuration} for Moonshot Chat Model. + * + * @author Geng Rong + */ +@AutoConfiguration(after = { RestClientAutoConfiguration.class, SpringAiRetryAutoConfiguration.class }) +@EnableConfigurationProperties({ MoonshotCommonProperties.class, MoonshotChatProperties.class }) +@ConditionalOnClass(MoonshotApi.class) +public class MoonshotAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = MoonshotChatProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + public MoonshotChatModel moonshotChatModel(MoonshotCommonProperties commonProperties, + MoonshotChatProperties chatProperties, ObjectProvider restClientBuilderProvider, + List toolFunctionCallbacks, FunctionCallbackResolver functionCallbackResolver, + RetryTemplate retryTemplate, ResponseErrorHandler responseErrorHandler, + ObjectProvider observationRegistry, + ObjectProvider observationConvention) { + + var moonshotApi = moonshotApi(chatProperties.getApiKey(), commonProperties.getApiKey(), + chatProperties.getBaseUrl(), commonProperties.getBaseUrl(), + restClientBuilderProvider.getIfAvailable(RestClient::builder), responseErrorHandler); + + var chatModel = new MoonshotChatModel(moonshotApi, chatProperties.getOptions(), functionCallbackResolver, + toolFunctionCallbacks, retryTemplate, observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)); + + observationConvention.ifAvailable(chatModel::setObservationConvention); + return chatModel; + } + + @Bean + @ConditionalOnMissingBean + public FunctionCallbackResolver springAiFunctionManager(ApplicationContext context) { + DefaultFunctionCallbackResolver manager = new DefaultFunctionCallbackResolver(); + manager.setApplicationContext(context); + return manager; + } + + private MoonshotApi moonshotApi(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, "Moonshot API key must be set"); + Assert.hasText(resoledBaseUrl, "Moonshot base URL must be set"); + + return new MoonshotApi(resoledBaseUrl, resolvedApiKey, restClientBuilder, responseErrorHandler); + } + +} diff --git a/auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/moonshot/MoonshotChatProperties.java b/auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/moonshot/MoonshotChatProperties.java new file mode 100644 index 00000000000..68d87ea25f9 --- /dev/null +++ b/auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/moonshot/MoonshotChatProperties.java @@ -0,0 +1,66 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.moonshot; + +import org.springframework.ai.moonshot.MoonshotChatOptions; +import org.springframework.ai.moonshot.api.MoonshotApi; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * Configuration properties for Moonshot chat client. + * + * @author Geng Rong + * @author Alexandros Pappas + */ +@ConfigurationProperties(MoonshotChatProperties.CONFIG_PREFIX) +public class MoonshotChatProperties extends MoonshotParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.moonshot.chat"; + + public static final String DEFAULT_CHAT_MODEL = MoonshotApi.ChatModel.MOONSHOT_V1_8K.getValue(); + + private static final Double DEFAULT_TEMPERATURE = 0.7; + + /** + * Enable Moonshot chat client. + */ + private boolean enabled = true; + + @NestedConfigurationProperty + private MoonshotChatOptions options = MoonshotChatOptions.builder() + .model(DEFAULT_CHAT_MODEL) + .temperature(DEFAULT_TEMPERATURE) + .build(); + + public MoonshotChatOptions getOptions() { + return this.options; + } + + public void setOptions(MoonshotChatOptions options) { + this.options = options; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + +} diff --git a/auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/moonshot/MoonshotCommonProperties.java b/auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/moonshot/MoonshotCommonProperties.java new file mode 100644 index 00000000000..db8989316b8 --- /dev/null +++ b/auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/moonshot/MoonshotCommonProperties.java @@ -0,0 +1,37 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.moonshot; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Parent properties for Moonshot. + * + * @author Geng Rong + */ +@ConfigurationProperties(MoonshotCommonProperties.CONFIG_PREFIX) +public class MoonshotCommonProperties extends MoonshotParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.moonshot"; + + public static final String DEFAULT_BASE_URL = "https://api.moonshot.cn"; + + public MoonshotCommonProperties() { + super.setBaseUrl(DEFAULT_BASE_URL); + } + +} diff --git a/auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/moonshot/MoonshotParentProperties.java b/auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/moonshot/MoonshotParentProperties.java new file mode 100644 index 00000000000..db0475c296c --- /dev/null +++ b/auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/moonshot/MoonshotParentProperties.java @@ -0,0 +1,46 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.moonshot; + +/** + * Parent properties for Moonshot. + * + * @author Geng Rong + */ +public class MoonshotParentProperties { + + 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-moonshot-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..7488990b98e --- /dev/null +++ b/auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/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.autoconfigure.moonshot.MoonshotAutoConfiguration diff --git a/auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/moonshot/MoonshotAutoConfigurationIT.java b/auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/moonshot/MoonshotAutoConfigurationIT.java new file mode 100644 index 00000000000..cf8cdd647a3 --- /dev/null +++ b/auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/moonshot/MoonshotAutoConfigurationIT.java @@ -0,0 +1,77 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.moonshot; + +import java.util.Objects; +import java.util.stream.Collectors; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import reactor.core.publisher.Flux; + +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.moonshot.MoonshotChatModel; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Geng Rong + */ +@EnabledIfEnvironmentVariable(named = "MOONSHOT_API_KEY", matches = ".*") +public class MoonshotAutoConfigurationIT { + + private static final Log logger = LogFactory.getLog(MoonshotAutoConfigurationIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.moonshot.apiKey=" + System.getenv("MOONSHOT_API_KEY")) + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MoonshotAutoConfiguration.class)); + + @Test + void generate() { + this.contextRunner.run(context -> { + MoonshotChatModel client = context.getBean(MoonshotChatModel.class); + String response = client.call("Hello"); + assertThat(response).isNotEmpty(); + logger.info("Response: " + response); + }); + } + + @Test + void generateStreaming() { + this.contextRunner.run(context -> { + MoonshotChatModel client = context.getBean(MoonshotChatModel.class); + Flux responseFlux = client.stream(new Prompt(new UserMessage("Hello"))); + String response = Objects.requireNonNull(responseFlux.collectList().block()) + .stream() + .map(chatResponse -> chatResponse.getResults().get(0).getOutput().getText()) + .collect(Collectors.joining()); + + assertThat(response).isNotEmpty(); + logger.info("Response: " + response); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/moonshot/MoonshotPropertiesTests.java b/auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/moonshot/MoonshotPropertiesTests.java new file mode 100644 index 00000000000..79e7bd14265 --- /dev/null +++ b/auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/moonshot/MoonshotPropertiesTests.java @@ -0,0 +1,166 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.moonshot; + +import org.junit.jupiter.api.Test; + +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.moonshot.MoonshotChatModel; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Geng Rong + */ +public class MoonshotPropertiesTests { + + @Test + public void chatProperties() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.moonshot.base-url=TEST_BASE_URL", + "spring.ai.moonshot.api-key=abc123", + "spring.ai.moonshot.chat.options.model=MODEL_XYZ", + "spring.ai.moonshot.chat.options.temperature=0.55") + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MoonshotAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(MoonshotChatProperties.class); + var connectionProperties = context.getBean(MoonshotCommonProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(chatProperties.getApiKey()).isNull(); + assertThat(chatProperties.getBaseUrl()).isNull(); + + assertThat(chatProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55); + }); + } + + @Test + public void chatOverrideConnectionProperties() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.moonshot.base-url=TEST_BASE_URL", + "spring.ai.moonshot.api-key=abc123", + "spring.ai.moonshot.chat.base-url=TEST_BASE_URL2", + "spring.ai.moonshot.chat.api-key=456", + "spring.ai.moonshot.chat.options.model=MODEL_XYZ", + "spring.ai.moonshot.chat.options.temperature=0.55") + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MoonshotAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(MoonshotChatProperties.class); + var connectionProperties = context.getBean(MoonshotCommonProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(chatProperties.getApiKey()).isEqualTo("456"); + assertThat(chatProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL2"); + + assertThat(chatProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55); + }); + } + + @Test + public void chatOptionsTest() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.moonshot.api-key=API_KEY", + "spring.ai.moonshot.base-url=TEST_BASE_URL", + + "spring.ai.moonshot.chat.options.model=MODEL_XYZ", + "spring.ai.moonshot.chat.options.frequencyPenalty=-1.5", + "spring.ai.moonshot.chat.options.logitBias.myTokenId=-5", + "spring.ai.moonshot.chat.options.maxTokens=123", + "spring.ai.moonshot.chat.options.n=10", + "spring.ai.moonshot.chat.options.presencePenalty=0", + "spring.ai.moonshot.chat.options.responseFormat.type=json", + "spring.ai.moonshot.chat.options.seed=66", + "spring.ai.moonshot.chat.options.stop=boza,koza", + "spring.ai.moonshot.chat.options.temperature=0.55", + "spring.ai.moonshot.chat.options.topP=0.56", + "spring.ai.moonshot.chat.options.user=userXYZ" + ) + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MoonshotAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(MoonshotChatProperties.class); + var connectionProperties = context.getBean(MoonshotCommonProperties.class); + + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY"); + + assertThat(chatProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(chatProperties.getOptions().getFrequencyPenalty()).isEqualTo(-1.5); + assertThat(chatProperties.getOptions().getMaxTokens()).isEqualTo(123); + assertThat(chatProperties.getOptions().getN()).isEqualTo(10); + assertThat(chatProperties.getOptions().getPresencePenalty()).isEqualTo(0); + assertThat(chatProperties.getOptions().getStop()).contains("boza", "koza"); + assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55); + assertThat(chatProperties.getOptions().getTopP()).isEqualTo(0.56); + + assertThat(chatProperties.getOptions().getUser()).isEqualTo("userXYZ"); + }); + } + + @Test + void chatActivation() { + new ApplicationContextRunner() + .withPropertyValues("spring.ai.moonshot.api-key=API_KEY", "spring.ai.moonshot.base-url=TEST_BASE_URL", + "spring.ai.moonshot.chat.enabled=false") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MoonshotAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(MoonshotChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(MoonshotChatModel.class)).isEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.moonshot.api-key=API_KEY", "spring.ai.moonshot.base-url=TEST_BASE_URL") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MoonshotAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(MoonshotChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(MoonshotChatModel.class)).isNotEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.moonshot.api-key=API_KEY", "spring.ai.moonshot.base-url=TEST_BASE_URL", + "spring.ai.moonshot.chat.enabled=true") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MoonshotAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(MoonshotChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(MoonshotChatModel.class)).isNotEmpty(); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/moonshot/tool/FunctionCallbackInPromptIT.java b/auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/moonshot/tool/FunctionCallbackInPromptIT.java new file mode 100644 index 00000000000..bd20fbb9d16 --- /dev/null +++ b/auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/moonshot/tool/FunctionCallbackInPromptIT.java @@ -0,0 +1,119 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.moonshot.tool; + +import java.util.List; +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.autoconfigure.moonshot.MoonshotAutoConfiguration; +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.chat.messages.AssistantMessage; +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.Prompt; +import org.springframework.ai.model.function.FunctionCallback; +import org.springframework.ai.moonshot.MoonshotChatModel; +import org.springframework.ai.moonshot.MoonshotChatOptions; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Geng Rong + * @author Alexandros Pappas + */ +@EnabledIfEnvironmentVariable(named = "MOONSHOT_API_KEY", matches = ".*") +public class FunctionCallbackInPromptIT { + + private final Logger logger = LoggerFactory.getLogger(FunctionCallbackInPromptIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.moonshot.apiKey=" + System.getenv("MOONSHOT_API_KEY")) + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MoonshotAutoConfiguration.class)); + + @Test + void functionCallTest() { + this.contextRunner.run(context -> { + + MoonshotChatModel chatModel = context.getBean(MoonshotChatModel.class); + + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius"); + + var promptOptions = MoonshotChatOptions.builder() + .functionCallbacks(List.of(FunctionCallback.builder() + .function("CurrentWeatherService", new MockWeatherService()) + .description("Get the weather in location") + .inputType(MockWeatherService.Request.class) + .build())) + .build(); + + ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), promptOptions)); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + }); + } + + @Test + void streamingFunctionCallTest() { + + this.contextRunner.run(context -> { + + MoonshotChatModel chatModel = context.getBean(MoonshotChatModel.class); + + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius"); + + var promptOptions = MoonshotChatOptions.builder() + .functionCallbacks(List.of(FunctionCallback.builder() + .function("CurrentWeatherService", new MockWeatherService()) + .description("Get the weather in location") + .inputType(MockWeatherService.Request.class) + .build())) + .build(); + + Flux response = chatModel.stream(new Prompt(List.of(userMessage), 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("30.0", "30"); + assertThat(content).containsAnyOf("10.0", "10"); + assertThat(content).containsAnyOf("15.0", "15"); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/moonshot/tool/FunctionCallbackWithPlainFunctionBeanIT.java b/auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/moonshot/tool/FunctionCallbackWithPlainFunctionBeanIT.java new file mode 100644 index 00000000000..a9beae2cf90 --- /dev/null +++ b/auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/moonshot/tool/FunctionCallbackWithPlainFunctionBeanIT.java @@ -0,0 +1,177 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.moonshot.tool; + +import java.util.List; +import java.util.function.Function; +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.autoconfigure.moonshot.MoonshotAutoConfiguration; +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.chat.messages.AssistantMessage; +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.Prompt; +import org.springframework.ai.model.function.FunctionCallingOptions; +import org.springframework.ai.moonshot.MoonshotChatModel; +import org.springframework.ai.moonshot.MoonshotChatOptions; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Description; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Geng Rong + * @author Alexandros Pappas + */ +@EnabledIfEnvironmentVariable(named = "MOONSHOT_API_KEY", matches = ".*") +class FunctionCallbackWithPlainFunctionBeanIT { + + private final Logger logger = LoggerFactory.getLogger(FunctionCallbackWithPlainFunctionBeanIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.moonshot.apiKey=" + System.getenv("MOONSHOT_API_KEY")) + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MoonshotAutoConfiguration.class)) + .withUserConfiguration(Config.class); + + @Test + void functionCallTest() { + this.contextRunner.run(context -> { + + MoonshotChatModel chatModel = context.getBean(MoonshotChatModel.class); + + // Test weatherFunction + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius"); + + ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), + MoonshotChatOptions.builder().function("weatherFunction").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + + // Test weatherFunctionTwo + response = chatModel.call(new Prompt(List.of(userMessage), + MoonshotChatOptions.builder().function("weatherFunctionTwo").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + + }); + } + + @Test + void functionCallWithPortableFunctionCallingOptions() { + this.contextRunner.run(context -> { + + MoonshotChatModel chatModel = context.getBean(MoonshotChatModel.class); + + // Test weatherFunction + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius"); + + FunctionCallingOptions functionOptions = FunctionCallingOptions.builder() + .function("weatherFunction") + .build(); + + ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), functionOptions)); + + logger.info("Response: {}", response); + }); + } + + @Test + void streamFunctionCallTest() { + this.contextRunner.run(context -> { + + MoonshotChatModel chatModel = context.getBean(MoonshotChatModel.class); + + // Test weatherFunction + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius"); + + Flux response = chatModel.stream(new Prompt(List.of(userMessage), + MoonshotChatOptions.builder().function("weatherFunction").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("30.0", "30"); + assertThat(content).containsAnyOf("10.0", "10"); + assertThat(content).containsAnyOf("15.0", "15"); + + // Test weatherFunctionTwo + response = chatModel.stream(new Prompt(List.of(userMessage), + MoonshotChatOptions.builder().function("weatherFunctionTwo").build())); + + 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("30.0", "30"); + assertThat(content).containsAnyOf("10.0", "10"); + assertThat(content).containsAnyOf("15.0", "15"); + }); + } + + @Configuration + static class Config { + + @Bean + @Description("Get the weather in location") + public Function weatherFunction() { + return new MockWeatherService(); + } + + // Relies on the Request's JsonClassDescription annotation to provide the + // function description. + @Bean + public Function weatherFunctionTwo() { + MockWeatherService weatherService = new MockWeatherService(); + return (weatherService::apply); + } + + } + +} diff --git a/auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/moonshot/tool/MockWeatherService.java b/auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/moonshot/tool/MockWeatherService.java new file mode 100644 index 00000000000..935464dca4e --- /dev/null +++ b/auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/moonshot/tool/MockWeatherService.java @@ -0,0 +1,95 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.moonshot.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; + +/** + * Mock 3rd party weather service. + * + * @author Geng Rong + */ +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/auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/moonshot/tool/MoonshotFunctionCallbackIT.java b/auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/moonshot/tool/MoonshotFunctionCallbackIT.java new file mode 100644 index 00000000000..fed437ef665 --- /dev/null +++ b/auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/moonshot/tool/MoonshotFunctionCallbackIT.java @@ -0,0 +1,126 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.moonshot.tool; + +import java.util.List; +import java.util.Objects; +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.autoconfigure.moonshot.MoonshotAutoConfiguration; +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.chat.messages.AssistantMessage; +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.Prompt; +import org.springframework.ai.model.function.FunctionCallback; +import org.springframework.ai.moonshot.MoonshotChatModel; +import org.springframework.ai.moonshot.MoonshotChatOptions; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Geng Rong + * @author Alexandros Pappas + */ +@EnabledIfEnvironmentVariable(named = "MOONSHOT_API_KEY", matches = ".*") +public class MoonshotFunctionCallbackIT { + + private final Logger logger = LoggerFactory.getLogger(MoonshotFunctionCallbackIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.moonshot.apiKey=" + System.getenv("MOONSHOT_API_KEY")) + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MoonshotAutoConfiguration.class)) + .withUserConfiguration(Config.class); + + @Test + void functionCallTest() { + this.contextRunner.run(context -> { + + MoonshotChatModel chatModel = context.getBean(MoonshotChatModel.class); + + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius"); + + ChatResponse response = chatModel + .call(new Prompt(List.of(userMessage), MoonshotChatOptions.builder().function("WeatherInfo").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + + }); + } + + @Test + void streamFunctionCallTest() { + this.contextRunner.run(context -> { + + MoonshotChatModel chatModel = context.getBean(MoonshotChatModel.class); + + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius"); + + Flux response = chatModel.stream( + new Prompt(List.of(userMessage), MoonshotChatOptions.builder().function("WeatherInfo").build())); + + String content = response.collectList() + .block() + .stream() + .map(ChatResponse::getResults) + .flatMap(List::stream) + .map(Generation::getOutput) + .map(AssistantMessage::getText) + .filter(Objects::nonNull) + .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"); + + }); + } + + @Configuration + static class Config { + + @Bean + public FunctionCallback weatherFunctionInfo() { + + return FunctionCallback.builder() + .function("WeatherInfo", new MockWeatherService()) + .description("Get the weather in location") + .inputType(MockWeatherService.Request.class) + .build(); + } + + } + +} diff --git a/auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure/pom.xml b/auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure/pom.xml new file mode 100644 index 00000000000..faae9d53d7b --- /dev/null +++ b/auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure/pom.xml @@ -0,0 +1,93 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../../pom.xml + + spring-ai-oci-genai-spring-boot-autoconfigure + jar + Spring AI OCI GenAI Auto Configuration + Spring AI OCI GenAI 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-oci-genai + ${project.parent.version} + true + + + + + + org.springframework.ai + spring-ai-chat-client-spring-boot-autoconfigure + ${project.parent.version} + true + + + + org.springframework.ai + spring-ai-chat-model-spring-boot-autoconfigure + ${project.parent.version} + true + + + + org.springframework.ai + spring-ai-retry-spring-boot-autoconfigure + ${project.parent.version} + true + + + + + org.springframework.boot + spring-boot-starter + true + + + + org.springframework.boot + spring-boot-configuration-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-oci-genai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/oci/genai/OCICohereChatModelProperties.java b/auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/oci/genai/OCICohereChatModelProperties.java new file mode 100644 index 00000000000..9f97e5978ae --- /dev/null +++ b/auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/oci/genai/OCICohereChatModelProperties.java @@ -0,0 +1,61 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.oci.genai; + +import org.springframework.ai.oci.cohere.OCICohereChatOptions; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * Configuration properties for OCI Cohere chat model. + * + * @author Anders Swanson + */ +@ConfigurationProperties(OCICohereChatModelProperties.CONFIG_PREFIX) +public class OCICohereChatModelProperties { + + public static final String CONFIG_PREFIX = "spring.ai.oci.genai.cohere.chat"; + + private static final String DEFAULT_SERVING_MODE = ServingMode.ON_DEMAND.getMode(); + + private static final Double DEFAULT_TEMPERATURE = 0.7; + + private boolean enabled; + + @NestedConfigurationProperty + private OCICohereChatOptions options = OCICohereChatOptions.builder() + .servingMode(DEFAULT_SERVING_MODE) + .temperature(DEFAULT_TEMPERATURE) + .build(); + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public OCICohereChatOptions getOptions() { + return this.options; + } + + public void setOptions(OCICohereChatOptions options) { + this.options = options; + } + +} diff --git a/auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/oci/genai/OCIConnectionProperties.java b/auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/oci/genai/OCIConnectionProperties.java new file mode 100644 index 00000000000..e22f573d099 --- /dev/null +++ b/auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/oci/genai/OCIConnectionProperties.java @@ -0,0 +1,153 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.oci.genai; + +import java.nio.file.Paths; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.util.StringUtils; + +/** + * Configuration properties for OCI connection. + * + * @author Anders Swanson + */ +@ConfigurationProperties(OCIConnectionProperties.CONFIG_PREFIX) +public class OCIConnectionProperties { + + public static final String CONFIG_PREFIX = "spring.ai.oci.genai"; + + private static final String DEFAULT_PROFILE = "DEFAULT"; + + private AuthenticationType authenticationType = AuthenticationType.FILE; + + private String profile; + + private String file = Paths.get(System.getProperty("user.home"), ".oci", "config").toString(); + + private String tenantId; + + private String userId; + + private String fingerprint; + + private String privateKey; + + private String passPhrase; + + private String region = "us-chicago-1"; + + private String endpoint; + + public String getRegion() { + return this.region; + } + + public void setRegion(String region) { + this.region = region; + } + + public String getPassPhrase() { + return this.passPhrase; + } + + public void setPassPhrase(String passPhrase) { + this.passPhrase = passPhrase; + } + + public String getPrivateKey() { + return this.privateKey; + } + + public void setPrivateKey(String privateKey) { + this.privateKey = privateKey; + } + + public String getFingerprint() { + return this.fingerprint; + } + + public void setFingerprint(String fingerprint) { + this.fingerprint = fingerprint; + } + + public String getUserId() { + return this.userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public String getTenantId() { + return this.tenantId; + } + + public void setTenantId(String tenantId) { + this.tenantId = tenantId; + } + + public String getFile() { + return this.file; + } + + public void setFile(String file) { + this.file = file; + } + + public String getProfile() { + return StringUtils.hasText(this.profile) ? this.profile : DEFAULT_PROFILE; + } + + public void setProfile(String profile) { + this.profile = profile; + } + + public AuthenticationType getAuthenticationType() { + return this.authenticationType; + } + + public void setAuthenticationType(AuthenticationType authenticationType) { + this.authenticationType = authenticationType; + } + + public String getEndpoint() { + return this.endpoint; + } + + public void setEndpoint(String endpoint) { + this.endpoint = endpoint; + } + + public enum AuthenticationType { + + FILE("file"), INSTANCE_PRINCIPAL("instance-principal"), WORKLOAD_IDENTITY("workload-identity"), + SIMPLE("simple"); + + private final String authType; + + AuthenticationType(String authType) { + this.authType = authType; + } + + public String getAuthType() { + return this.authType; + } + + } + +} diff --git a/auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/oci/genai/OCIEmbeddingModelProperties.java b/auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/oci/genai/OCIEmbeddingModelProperties.java new file mode 100644 index 00000000000..bbfeaf0a25a --- /dev/null +++ b/auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/oci/genai/OCIEmbeddingModelProperties.java @@ -0,0 +1,93 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.oci.genai; + +import com.oracle.bmc.generativeaiinference.model.EmbedTextDetails; + +import org.springframework.ai.oci.OCIEmbeddingOptions; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration properties for OCI embedding model. + * + * @author Anders Swanson + */ +@ConfigurationProperties(OCIEmbeddingModelProperties.CONFIG_PREFIX) +public class OCIEmbeddingModelProperties { + + public static final String CONFIG_PREFIX = "spring.ai.oci.genai.embedding"; + + private ServingMode servingMode = ServingMode.ON_DEMAND; + + private EmbedTextDetails.Truncate truncate = EmbedTextDetails.Truncate.End; + + private String compartment; + + private String model; + + private boolean enabled; + + public OCIEmbeddingOptions getEmbeddingOptions() { + return OCIEmbeddingOptions.builder() + .compartment(this.compartment) + .model(this.model) + .servingMode(this.servingMode.getMode()) + .truncate(this.truncate) + .build(); + } + + public ServingMode getServingMode() { + return this.servingMode; + } + + public void setServingMode(ServingMode servingMode) { + this.servingMode = servingMode; + } + + public String getCompartment() { + return this.compartment; + } + + public void setCompartment(String compartment) { + this.compartment = compartment; + } + + public String getModel() { + return this.model; + } + + public void setModel(String model) { + this.model = model; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public EmbedTextDetails.Truncate getTruncate() { + return this.truncate; + } + + public void setTruncate(EmbedTextDetails.Truncate truncate) { + this.truncate = truncate; + } + +} diff --git a/auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/oci/genai/OCIGenAiAutoConfiguration.java b/auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/oci/genai/OCIGenAiAutoConfiguration.java new file mode 100644 index 00000000000..fbf651edbef --- /dev/null +++ b/auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/oci/genai/OCIGenAiAutoConfiguration.java @@ -0,0 +1,113 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.oci.genai; + +import java.io.IOException; + +import com.oracle.bmc.ClientConfiguration; +import com.oracle.bmc.Region; +import com.oracle.bmc.auth.BasicAuthenticationDetailsProvider; +import com.oracle.bmc.auth.ConfigFileAuthenticationDetailsProvider; +import com.oracle.bmc.auth.InstancePrincipalsAuthenticationDetailsProvider; +import com.oracle.bmc.auth.SimpleAuthenticationDetailsProvider; +import com.oracle.bmc.auth.SimplePrivateKeySupplier; +import com.oracle.bmc.auth.okeworkloadidentity.OkeWorkloadIdentityAuthenticationDetailsProvider; +import com.oracle.bmc.generativeaiinference.GenerativeAiInferenceClient; +import com.oracle.bmc.retrier.RetryConfiguration; +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.ai.chat.observation.ChatModelObservationConvention; +import org.springframework.ai.oci.OCIEmbeddingModel; +import org.springframework.ai.oci.cohere.OCICohereChatModel; +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.context.annotation.Bean; +import org.springframework.util.StringUtils; + +/** + * {@link AutoConfiguration Auto-configuration} for Oracle Cloud Infrastructure Generative + * AI. + * + * @author Anders Swanson + */ +@AutoConfiguration +@ConditionalOnClass({ GenerativeAiInferenceClient.class, OCIEmbeddingModel.class }) +@EnableConfigurationProperties({ OCIConnectionProperties.class, OCIEmbeddingModelProperties.class, + OCICohereChatModelProperties.class }) +public class OCIGenAiAutoConfiguration { + + private static BasicAuthenticationDetailsProvider authenticationProvider(OCIConnectionProperties properties) + throws IOException { + return switch (properties.getAuthenticationType()) { + case FILE -> new ConfigFileAuthenticationDetailsProvider(properties.getFile(), properties.getProfile()); + case INSTANCE_PRINCIPAL -> InstancePrincipalsAuthenticationDetailsProvider.builder().build(); + case WORKLOAD_IDENTITY -> OkeWorkloadIdentityAuthenticationDetailsProvider.builder().build(); + case SIMPLE -> SimpleAuthenticationDetailsProvider.builder() + .userId(properties.getUserId()) + .tenantId(properties.getTenantId()) + .fingerprint(properties.getFingerprint()) + .privateKeySupplier(new SimplePrivateKeySupplier(properties.getPrivateKey())) + .passPhrase(properties.getPassPhrase()) + .region(Region.valueOf(properties.getRegion())) + .build(); + }; + } + + @ConditionalOnMissingBean + @Bean + public GenerativeAiInferenceClient generativeAiInferenceClient(OCIConnectionProperties properties) + throws IOException { + ClientConfiguration clientConfiguration = ClientConfiguration.builder() + .retryConfiguration(RetryConfiguration.SDK_DEFAULT_RETRY_CONFIGURATION) + .build(); + GenerativeAiInferenceClient.Builder builder = GenerativeAiInferenceClient.builder() + .configuration(clientConfiguration); + if (StringUtils.hasText(properties.getRegion())) { + builder.region(Region.valueOf(properties.getRegion())); + } + if (StringUtils.hasText(properties.getEndpoint())) { + builder.endpoint(properties.getEndpoint()); + } + return builder.build(authenticationProvider(properties)); + } + + @Bean + @ConditionalOnProperty(prefix = OCIEmbeddingModelProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + public OCIEmbeddingModel ociEmbeddingModel(GenerativeAiInferenceClient generativeAiClient, + OCIEmbeddingModelProperties properties) { + return new OCIEmbeddingModel(generativeAiClient, properties.getEmbeddingOptions()); + } + + @Bean + @ConditionalOnProperty(prefix = OCICohereChatModelProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + public OCICohereChatModel ociChatModel(GenerativeAiInferenceClient generativeAiClient, + OCICohereChatModelProperties properties, ObjectProvider observationRegistry, + ObjectProvider observationConvention) { + var chatModel = new OCICohereChatModel(generativeAiClient, properties.getOptions(), + observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)); + observationConvention.ifAvailable(chatModel::setObservationConvention); + + return chatModel; + } + +} diff --git a/auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/oci/genai/ServingMode.java b/auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/oci/genai/ServingMode.java new file mode 100644 index 00000000000..d777b8d958e --- /dev/null +++ b/auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/oci/genai/ServingMode.java @@ -0,0 +1,38 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.oci.genai; + +/** + * OCI serving mode. + * + * @author Anders Swanson + */ +public enum ServingMode { + + ON_DEMAND("on-demand"), DEDICATED("dedicated"); + + private final String mode; + + ServingMode(String mode) { + this.mode = mode; + } + + public String getMode() { + return this.mode; + } + +} diff --git a/auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..d87ac5f0c09 --- /dev/null +++ b/auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure/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.autoconfigure.oci.genai.OCIGenAiAutoConfiguration diff --git a/auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/oci/genai/OCIGenAIAutoConfigurationTest.java b/auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/oci/genai/OCIGenAIAutoConfigurationTest.java new file mode 100644 index 00000000000..d7b9573ff78 --- /dev/null +++ b/auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/oci/genai/OCIGenAIAutoConfigurationTest.java @@ -0,0 +1,93 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.oci.genai; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; + +import com.oracle.bmc.http.client.pki.Pem; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.ai.oci.cohere.OCICohereChatModel; +import org.springframework.ai.oci.cohere.OCICohereChatOptions; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +class OCIGenAIAutoConfigurationTest { + + @Test + void setProperties(@TempDir Path tempDir) throws Exception { + Path tmp = tempDir.resolve("my-key.pem"); + createPrivateKey(tmp); + ApplicationContextRunner contextRunner = new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.oci.genai.authenticationType=simple", + "spring.ai.oci.genai.userId=my-user", + "spring.ai.oci.genai.tenantId=my-tenant", + "spring.ai.oci.genai.fingerprint=xyz", + "spring.ai.oci.genai.privateKey=" + tmp.toAbsolutePath(), + "spring.ai.oci.genai.region=us-ashburn-1", + "spring.ai.oci.genai.cohere.chat.options.compartment=my-compartment", + "spring.ai.oci.genai.cohere.chat.options.servingMode=dedicated", + "spring.ai.oci.genai.cohere.chat.options.model=my-model", + "spring.ai.oci.genai.cohere.chat.options.maxTokens=1000", + "spring.ai.oci.genai.cohere.chat.options.temperature=0.5", + "spring.ai.oci.genai.cohere.chat.options.topP=0.8", + "spring.ai.oci.genai.cohere.chat.options.maxTokens=1000", + "spring.ai.oci.genai.cohere.chat.options.frequencyPenalty=0.1", + "spring.ai.oci.genai.cohere.chat.options.presencePenalty=0.2" + // @formatter:on + ).withConfiguration(AutoConfigurations.of(OCIGenAiAutoConfiguration.class)); + + contextRunner.run(context -> { + OCICohereChatModel chatModel = context.getBean(OCICohereChatModel.class); + assertThat(chatModel).isNotNull(); + OCICohereChatOptions options = (OCICohereChatOptions) chatModel.getDefaultOptions(); + assertThat(options.getCompartment()).isEqualTo("my-compartment"); + assertThat(options.getModel()).isEqualTo("my-model"); + assertThat(options.getServingMode()).isEqualTo("dedicated"); + assertThat(options.getMaxTokens()).isEqualTo(1000); + assertThat(options.getTemperature()).isEqualTo(0.5); + assertThat(options.getTopP()).isEqualTo(0.8); + assertThat(options.getFrequencyPenalty()).isEqualTo(0.1); + assertThat(options.getPresencePenalty()).isEqualTo(0.2); + + OCIConnectionProperties props = context.getBean(OCIConnectionProperties.class); + assertThat(props.getAuthenticationType()).isEqualTo(OCIConnectionProperties.AuthenticationType.SIMPLE); + assertThat(props.getUserId()).isEqualTo("my-user"); + assertThat(props.getTenantId()).isEqualTo("my-tenant"); + assertThat(props.getFingerprint()).isEqualTo("xyz"); + assertThat(props.getPrivateKey()).isEqualTo(tmp.toAbsolutePath().toString()); + assertThat(props.getRegion()).isEqualTo("us-ashburn-1"); + + }); + } + + private void createPrivateKey(Path tmp) throws Exception { + KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA"); + gen.initialize(2048); + KeyPair keyPair = gen.generateKeyPair(); + byte[] encoded = Pem.encoder().encode(keyPair.getPrivate()); + Files.write(tmp, encoded); + } + +} diff --git a/auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/oci/genai/OCIGenAiAutoConfigurationIT.java b/auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/oci/genai/OCIGenAiAutoConfigurationIT.java new file mode 100644 index 00000000000..24b7e5262bb --- /dev/null +++ b/auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/oci/genai/OCIGenAiAutoConfigurationIT.java @@ -0,0 +1,90 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.oci.genai; + +import java.nio.file.Paths; +import java.util.List; + +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.oci.OCIEmbeddingModel; +import org.springframework.ai.oci.cohere.OCICohereChatModel; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +@EnabledIfEnvironmentVariable(named = OCIGenAiAutoConfigurationIT.COMPARTMENT_ID_KEY, matches = ".+") +public class OCIGenAiAutoConfigurationIT { + + public static final String COMPARTMENT_ID_KEY = "OCI_COMPARTMENT_ID"; + + public static final String OCI_CHAT_MODEL_ID_KEY = "OCI_CHAT_MODEL_ID"; + + private final String CONFIG_FILE = Paths.get(System.getProperty("user.home"), ".oci", "config").toString(); + + private final String COMPARTMENT_ID = System.getenv(COMPARTMENT_ID_KEY); + + private final String CHAT_MODEL_ID = System.getenv(OCI_CHAT_MODEL_ID_KEY); + + private final ApplicationContextRunner embeddingContextRunner = new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.oci.genai.authenticationType=file", + "spring.ai.oci.genai.file=" + this.CONFIG_FILE, + "spring.ai.oci.genai.embedding.compartment=" + this.COMPARTMENT_ID, + "spring.ai.oci.genai.embedding.servingMode=on-demand", + "spring.ai.oci.genai.embedding.model=cohere.embed-english-light-v2.0" + // @formatter:on + ).withConfiguration(AutoConfigurations.of(OCIGenAiAutoConfiguration.class)); + + private final ApplicationContextRunner cohereChatContextRunner = new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.oci.genai.authenticationType=file", + "spring.ai.oci.genai.file=" + this.CONFIG_FILE, + "spring.ai.oci.genai.cohere.chat.options.compartment=" + this.COMPARTMENT_ID, + "spring.ai.oci.genai.cohere.chat.options.servingMode=on-demand", + "spring.ai.oci.genai.cohere.chat.options.model=" + this.CHAT_MODEL_ID + // @formatter:on + ).withConfiguration(AutoConfigurations.of(OCIGenAiAutoConfiguration.class)); + + @Test + void embeddings() { + this.embeddingContextRunner.run(context -> { + OCIEmbeddingModel embeddingModel = context.getBean(OCIEmbeddingModel.class); + assertThat(embeddingModel).isNotNull(); + EmbeddingResponse response = embeddingModel + .call(new EmbeddingRequest(List.of("There are 50 states in the USA", "Canada has 10 provinces"), null)); + assertThat(response).isNotNull(); + assertThat(response.getResults()).hasSize(2); + }); + } + + @Test + @EnabledIfEnvironmentVariable(named = OCIGenAiAutoConfigurationIT.OCI_CHAT_MODEL_ID_KEY, matches = ".+") + void cohereChat() { + this.cohereChatContextRunner.run(context -> { + OCICohereChatModel chatModel = context.getBean(OCICohereChatModel.class); + assertThat(chatModel).isNotNull(); + String response = chatModel.call("How many states are in the United States of America?"); + assertThat(response).isNotBlank(); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/pom.xml b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/pom.xml new file mode 100644 index 00000000000..1b0373531b2 --- /dev/null +++ b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/pom.xml @@ -0,0 +1,98 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../../pom.xml + + spring-ai-ollama-spring-boot-autoconfigure + jar + Spring AI Ollama Auto Configuration + Spring AI Ollama 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-ollama + ${project.parent.version} + true + + + + + + org.springframework.ai + spring-ai-chat-client-spring-boot-autoconfigure + ${project.parent.version} + true + + + + org.springframework.ai + spring-ai-chat-model-spring-boot-autoconfigure + ${project.parent.version} + true + + + + + org.springframework.boot + spring-boot-starter + true + + + + org.springframework.boot + spring-boot-configuration-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 + + + + org.testcontainers + junit-jupiter + test + + + + org.testcontainers + ollama + test + + + + diff --git a/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/ollama/OllamaAutoConfiguration.java b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/ollama/OllamaAutoConfiguration.java new file mode 100644 index 00000000000..ecde0c935a6 --- /dev/null +++ b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/ollama/OllamaAutoConfiguration.java @@ -0,0 +1,151 @@ +/* + * 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.autoconfigure.ollama; + +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.ai.autoconfigure.chat.model.ToolCallingAutoConfiguration; +import org.springframework.ai.chat.observation.ChatModelObservationConvention; +import org.springframework.ai.embedding.observation.EmbeddingModelObservationConvention; +import org.springframework.ai.model.function.DefaultFunctionCallbackResolver; +import org.springframework.ai.model.function.FunctionCallbackResolver; +import org.springframework.ai.model.tool.ToolCallingManager; +import org.springframework.ai.ollama.OllamaChatModel; +import org.springframework.ai.ollama.OllamaEmbeddingModel; +import org.springframework.ai.ollama.api.OllamaApi; +import org.springframework.ai.ollama.management.ModelManagementOptions; +import org.springframework.ai.ollama.management.PullModelStrategy; +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.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.web.client.RestClient; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * {@link AutoConfiguration Auto-configuration} for Ollama Chat Client. + * + * @author Christian Tzolov + * @author Eddú Meléndez + * @author Thomas Vitale + * @since 0.8.0 + */ +@AutoConfiguration(after = { RestClientAutoConfiguration.class, ToolCallingAutoConfiguration.class }) +@ConditionalOnClass(OllamaApi.class) +@EnableConfigurationProperties({ OllamaChatProperties.class, OllamaEmbeddingProperties.class, + OllamaConnectionProperties.class, OllamaInitializationProperties.class }) +@ImportAutoConfiguration(classes = { RestClientAutoConfiguration.class, ToolCallingAutoConfiguration.class, + WebClientAutoConfiguration.class }) +public class OllamaAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(OllamaConnectionDetails.class) + public PropertiesOllamaConnectionDetails ollamaConnectionDetails(OllamaConnectionProperties properties) { + return new PropertiesOllamaConnectionDetails(properties); + } + + @Bean + @ConditionalOnMissingBean + public OllamaApi ollamaApi(OllamaConnectionDetails connectionDetails, + ObjectProvider restClientBuilderProvider, + ObjectProvider webClientBuilderProvider) { + return new OllamaApi(connectionDetails.getBaseUrl(), + restClientBuilderProvider.getIfAvailable(RestClient::builder), + webClientBuilderProvider.getIfAvailable(WebClient::builder)); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = OllamaChatProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + public OllamaChatModel ollamaChatModel(OllamaApi ollamaApi, OllamaChatProperties properties, + OllamaInitializationProperties initProperties, ToolCallingManager toolCallingManager, + ObjectProvider observationRegistry, + ObjectProvider observationConvention) { + var chatModelPullStrategy = initProperties.getChat().isInclude() ? initProperties.getPullModelStrategy() + : PullModelStrategy.NEVER; + + var chatModel = OllamaChatModel.builder() + .ollamaApi(ollamaApi) + .defaultOptions(properties.getOptions()) + .toolCallingManager(toolCallingManager) + .observationRegistry(observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)) + .modelManagementOptions( + new ModelManagementOptions(chatModelPullStrategy, initProperties.getChat().getAdditionalModels(), + initProperties.getTimeout(), initProperties.getMaxRetries())) + .build(); + + observationConvention.ifAvailable(chatModel::setObservationConvention); + + return chatModel; + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = OllamaEmbeddingProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + public OllamaEmbeddingModel ollamaEmbeddingModel(OllamaApi ollamaApi, OllamaEmbeddingProperties properties, + OllamaInitializationProperties initProperties, ObjectProvider observationRegistry, + ObjectProvider observationConvention) { + var embeddingModelPullStrategy = initProperties.getEmbedding().isInclude() + ? initProperties.getPullModelStrategy() : PullModelStrategy.NEVER; + + var embeddingModel = OllamaEmbeddingModel.builder() + .ollamaApi(ollamaApi) + .defaultOptions(properties.getOptions()) + .observationRegistry(observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)) + .modelManagementOptions(new ModelManagementOptions(embeddingModelPullStrategy, + initProperties.getEmbedding().getAdditionalModels(), initProperties.getTimeout(), + initProperties.getMaxRetries())) + .build(); + + observationConvention.ifAvailable(embeddingModel::setObservationConvention); + + return embeddingModel; + } + + @Bean + @ConditionalOnMissingBean + public FunctionCallbackResolver springAiFunctionManager(ApplicationContext context) { + DefaultFunctionCallbackResolver manager = new DefaultFunctionCallbackResolver(); + manager.setApplicationContext(context); + return manager; + } + + static class PropertiesOllamaConnectionDetails implements OllamaConnectionDetails { + + private final OllamaConnectionProperties properties; + + PropertiesOllamaConnectionDetails(OllamaConnectionProperties properties) { + this.properties = properties; + } + + @Override + public String getBaseUrl() { + return this.properties.getBaseUrl(); + } + + } + +} diff --git a/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/ollama/OllamaChatProperties.java b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/ollama/OllamaChatProperties.java new file mode 100644 index 00000000000..a835495b5c3 --- /dev/null +++ b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/ollama/OllamaChatProperties.java @@ -0,0 +1,68 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.ollama; + +import org.springframework.ai.ollama.api.OllamaModel; +import org.springframework.ai.ollama.api.OllamaOptions; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * Ollama Chat autoconfiguration properties. + * + * @author Christian Tzolov + * @since 0.8.0 + */ +@ConfigurationProperties(OllamaChatProperties.CONFIG_PREFIX) +public class OllamaChatProperties { + + public static final String CONFIG_PREFIX = "spring.ai.ollama.chat"; + + /** + * Enable Ollama chat model. + */ + private boolean enabled = true; + + /** + * Client lever Ollama options. Use this property to configure generative temperature, + * topK and topP and alike parameters. The null values are ignored defaulting to the + * generative's defaults. + */ + @NestedConfigurationProperty + private OllamaOptions options = OllamaOptions.builder().model(OllamaModel.MISTRAL.id()).build(); + + public String getModel() { + return this.options.getModel(); + } + + public void setModel(String model) { + this.options.setModel(model); + } + + public OllamaOptions getOptions() { + return this.options; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + +} diff --git a/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/ollama/OllamaConnectionDetails.java b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/ollama/OllamaConnectionDetails.java new file mode 100644 index 00000000000..8673c3e361e --- /dev/null +++ b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/ollama/OllamaConnectionDetails.java @@ -0,0 +1,30 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.ollama; + +import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails; + +/** + * Connection details for an Ollama service. + * + * @author Eddú Meléndez + */ +public interface OllamaConnectionDetails extends ConnectionDetails { + + String getBaseUrl(); + +} diff --git a/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/ollama/OllamaConnectionProperties.java b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/ollama/OllamaConnectionProperties.java new file mode 100644 index 00000000000..46f127e1310 --- /dev/null +++ b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/ollama/OllamaConnectionProperties.java @@ -0,0 +1,45 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.ollama; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Ollama connection autoconfiguration properties. + * + * @author Christian Tzolov + * @since 0.8.0 + */ +@ConfigurationProperties(OllamaConnectionProperties.CONFIG_PREFIX) +public class OllamaConnectionProperties { + + public static final String CONFIG_PREFIX = "spring.ai.ollama"; + + /** + * Base URL where Ollama API server is running. + */ + private String baseUrl = "http://localhost:11434"; + + public String getBaseUrl() { + return this.baseUrl; + } + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + +} diff --git a/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/ollama/OllamaEmbeddingProperties.java b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/ollama/OllamaEmbeddingProperties.java new file mode 100644 index 00000000000..3a12ebc2d44 --- /dev/null +++ b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/ollama/OllamaEmbeddingProperties.java @@ -0,0 +1,68 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.ollama; + +import org.springframework.ai.ollama.api.OllamaModel; +import org.springframework.ai.ollama.api.OllamaOptions; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * Ollama Embedding autoconfiguration properties. + * + * @author Christian Tzolov + * @since 0.8.0 + */ +@ConfigurationProperties(OllamaEmbeddingProperties.CONFIG_PREFIX) +public class OllamaEmbeddingProperties { + + public static final String CONFIG_PREFIX = "spring.ai.ollama.embedding"; + + /** + * Enable Ollama embedding model. + */ + private boolean enabled = true; + + /** + * Client lever Ollama options. Use this property to configure generative temperature, + * topK and topP and alike parameters. The null values are ignored defaulting to the + * generative's defaults. + */ + @NestedConfigurationProperty + private OllamaOptions options = OllamaOptions.builder().model(OllamaModel.MXBAI_EMBED_LARGE.id()).build(); + + public String getModel() { + return this.options.getModel(); + } + + public void setModel(String model) { + this.options.setModel(model); + } + + public OllamaOptions getOptions() { + return this.options; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + +} diff --git a/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/ollama/OllamaInitializationProperties.java b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/ollama/OllamaInitializationProperties.java new file mode 100644 index 00000000000..54c764f274c --- /dev/null +++ b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/ollama/OllamaInitializationProperties.java @@ -0,0 +1,124 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.ollama; + +import java.time.Duration; +import java.util.List; + +import org.springframework.ai.ollama.management.PullModelStrategy; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Ollama initialization configuration properties. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +@ConfigurationProperties(OllamaInitializationProperties.CONFIG_PREFIX) +public class OllamaInitializationProperties { + + public static final String CONFIG_PREFIX = "spring.ai.ollama.init"; + + /** + * Chat models initialization settings. + */ + private final ModelTypeInit chat = new ModelTypeInit(); + + /** + * Embedding models initialization settings. + */ + private final ModelTypeInit embedding = new ModelTypeInit(); + + /** + * Whether to pull models at startup-time and how. + */ + private PullModelStrategy pullModelStrategy = PullModelStrategy.NEVER; + + /** + * How long to wait for a model to be pulled. + */ + private Duration timeout = Duration.ofMinutes(5); + + /** + * Maximum number of retries for the model pull operation. + */ + private int maxRetries = 0; + + public PullModelStrategy getPullModelStrategy() { + return this.pullModelStrategy; + } + + public void setPullModelStrategy(PullModelStrategy pullModelStrategy) { + this.pullModelStrategy = pullModelStrategy; + } + + public ModelTypeInit getChat() { + return this.chat; + } + + public ModelTypeInit getEmbedding() { + return this.embedding; + } + + public Duration getTimeout() { + return this.timeout; + } + + public void setTimeout(Duration timeout) { + this.timeout = timeout; + } + + public int getMaxRetries() { + return this.maxRetries; + } + + public void setMaxRetries(int maxRetries) { + this.maxRetries = maxRetries; + } + + public static class ModelTypeInit { + + /** + * Include this type of models in the initialization task. + */ + private boolean include = true; + + /** + * Additional models to initialize besides the ones configured via default + * properties. + */ + private List additionalModels = List.of(); + + public boolean isInclude() { + return this.include; + } + + public void setInclude(boolean include) { + this.include = include; + } + + public List getAdditionalModels() { + return this.additionalModels; + } + + public void setAdditionalModels(List additionalModels) { + this.additionalModels = additionalModels; + } + + } + +} diff --git a/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..82bc5c614b6 --- /dev/null +++ b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/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.autoconfigure.ollama.OllamaAutoConfiguration diff --git a/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/BaseOllamaIT.java b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/BaseOllamaIT.java new file mode 100644 index 00000000000..138ec88f13a --- /dev/null +++ b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/BaseOllamaIT.java @@ -0,0 +1,115 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.ollama; + +import java.time.Duration; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.ollama.OllamaContainer; + +import org.springframework.ai.ollama.api.OllamaApi; +import org.springframework.ai.ollama.management.ModelManagementOptions; +import org.springframework.ai.ollama.management.OllamaModelManager; +import org.springframework.ai.ollama.management.PullModelStrategy; +import org.springframework.util.Assert; + +@Testcontainers +@EnabledIfEnvironmentVariable(named = "OLLAMA_AUTOCONF_TESTS_ENABLED", matches = "true") +public abstract class BaseOllamaIT { + + static { + System.out.println("OLLAMA_AUTOCONF_TESTS_ENABLED=" + System.getenv("OLLAMA_AUTOCONF_TESTS_ENABLED")); + System.out.println("System property=" + System.getProperty("OLLAMA_AUTOCONF_TESTS_ENABLED")); + } + private static final String OLLAMA_LOCAL_URL = "http://localhost:11434"; + + private static final Duration DEFAULT_TIMEOUT = Duration.ofMinutes(10); + + private static final int DEFAULT_MAX_RETRIES = 2; + + // Environment variable to control whether to create a new container or use existing + // Ollama instance + private static final boolean SKIP_CONTAINER_CREATION = Boolean + .parseBoolean(System.getenv().getOrDefault("OLLAMA_WITH_REUSE", "false")); + + private static OllamaContainer ollamaContainer; + + private static final ThreadLocal ollamaApi = new ThreadLocal<>(); + + /** + * Initialize the Ollama API with the specified model. When OLLAMA_WITH_REUSE=true + * (default), uses TestContainers withReuse feature. When OLLAMA_WITH_REUSE=false, + * connects to local Ollama instance. + * @param model the Ollama model to initialize (must not be null or empty) + * @return configured OllamaApi instance + * @throws IllegalArgumentException if model is null or empty + */ + protected static OllamaApi initializeOllama(final String model) { + Assert.hasText(model, "Model name must be provided"); + + if (!SKIP_CONTAINER_CREATION) { + ollamaContainer = new OllamaContainer(OllamaImage.DEFAULT_IMAGE).withReuse(true); + ollamaContainer.start(); + } + + final OllamaApi api = buildOllamaApiWithModel(model); + ollamaApi.set(api); + return api; + } + + /** + * Get the initialized OllamaApi instance. + * @return the OllamaApi instance + * @throws IllegalStateException if called before initialization + */ + protected static OllamaApi getOllamaApi() { + OllamaApi api = ollamaApi.get(); + Assert.state(api != null, "OllamaApi not initialized. Call initializeOllama first."); + return api; + } + + @AfterAll + public static void tearDown() { + if (ollamaContainer != null) { + ollamaContainer.stop(); + } + } + + public static OllamaApi buildOllamaApiWithModel(final String model) { + final String baseUrl = SKIP_CONTAINER_CREATION ? OLLAMA_LOCAL_URL : ollamaContainer.getEndpoint(); + final OllamaApi api = new OllamaApi(baseUrl); + ensureModelIsPresent(api, model); + return api; + } + + public String getBaseUrl() { + String baseUrl = SKIP_CONTAINER_CREATION ? OLLAMA_LOCAL_URL : ollamaContainer.getEndpoint(); + return baseUrl; + } + + private static void ensureModelIsPresent(final OllamaApi ollamaApi, final String model) { + final var modelManagementOptions = ModelManagementOptions.builder() + .maxRetries(DEFAULT_MAX_RETRIES) + .timeout(DEFAULT_TIMEOUT) + .build(); + final var ollamaModelManager = new OllamaModelManager(ollamaApi, modelManagementOptions); + ollamaModelManager.pullModel(model, PullModelStrategy.WHEN_MISSING); + } + +} diff --git a/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/OllamaChatAutoConfigurationIT.java b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/OllamaChatAutoConfigurationIT.java new file mode 100644 index 00000000000..40d936968c4 --- /dev/null +++ b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/OllamaChatAutoConfigurationIT.java @@ -0,0 +1,133 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.ollama; + +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; + +import org.springframework.ai.chat.messages.AssistantMessage; +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.Prompt; +import org.springframework.ai.ollama.OllamaChatModel; +import org.springframework.ai.ollama.api.OllamaApi; +import org.springframework.ai.ollama.api.OllamaModel; +import org.springframework.ai.ollama.management.OllamaModelManager; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Christian Tzolov + * @author Eddú Meléndez + * @author Thomas Vitale + * @since 0.8.0 + */ +public class OllamaChatAutoConfigurationIT extends BaseOllamaIT { + + private static final String MODEL_NAME = OllamaModel.LLAMA3_2.getName(); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.ollama.baseUrl=" + getBaseUrl(), + "spring.ai.ollama.chat.options.model=" + MODEL_NAME, + "spring.ai.ollama.chat.options.temperature=0.5", + "spring.ai.ollama.chat.options.topK=10") + // @formatter:on + .withConfiguration(AutoConfigurations.of(OllamaAutoConfiguration.class)); + + private final UserMessage userMessage = new UserMessage("What's the capital of Denmark?"); + + @BeforeAll + public static void beforeAll() throws IOException, InterruptedException { + initializeOllama(MODEL_NAME); + } + + @Test + public void chatCompletion() { + this.contextRunner.run(context -> { + OllamaChatModel chatModel = context.getBean(OllamaChatModel.class); + ChatResponse response = chatModel.call(new Prompt(this.userMessage)); + assertThat(response.getResult().getOutput().getText()).contains("Copenhagen"); + }); + } + + @Test + public void chatCompletionStreaming() { + this.contextRunner.run(context -> { + + OllamaChatModel chatModel = context.getBean(OllamaChatModel.class); + + Flux response = chatModel.stream(new Prompt(this.userMessage)); + + List responses = response.collectList().block(); + assertThat(responses.size()).isGreaterThan(1); + + String stitchedResponseContent = responses.stream() + .map(ChatResponse::getResults) + .flatMap(List::stream) + .map(Generation::getOutput) + .map(AssistantMessage::getText) + .collect(Collectors.joining()); + + assertThat(stitchedResponseContent).contains("Copenhagen"); + }); + } + + @Test + public void chatCompletionWithPull() { + this.contextRunner.withPropertyValues("spring.ai.ollama.init.pull-model-strategy=when_missing") + .withPropertyValues("spring.ai.ollama.chat.options.model=tinyllama") + .run(context -> { + var model = "tinyllama"; + OllamaApi ollamaApi = context.getBean(OllamaApi.class); + var modelManager = new OllamaModelManager(ollamaApi); + assertThat(modelManager.isModelAvailable(model)).isTrue(); + + OllamaChatModel chatModel = context.getBean(OllamaChatModel.class); + ChatResponse response = chatModel.call(new Prompt(this.userMessage)); + assertThat(response.getResult().getOutput().getText()).contains("Copenhagen"); + modelManager.deleteModel(model); + }); + } + + @Test + void chatActivation() { + this.contextRunner.withPropertyValues("spring.ai.ollama.chat.enabled=false").run(context -> { + assertThat(context.getBeansOfType(OllamaChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OllamaChatModel.class)).isEmpty(); + }); + + this.contextRunner.run(context -> { + assertThat(context.getBeansOfType(OllamaChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OllamaChatModel.class)).isNotEmpty(); + }); + + this.contextRunner.withPropertyValues("spring.ai.ollama.chat.enabled=true").run(context -> { + assertThat(context.getBeansOfType(OllamaChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OllamaChatModel.class)).isNotEmpty(); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/OllamaChatAutoConfigurationTests.java b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/OllamaChatAutoConfigurationTests.java new file mode 100644 index 00000000000..14493bcb20c --- /dev/null +++ b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/OllamaChatAutoConfigurationTests.java @@ -0,0 +1,60 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.ollama; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Christian Tzolov + * @since 0.8.0 + */ +public class OllamaChatAutoConfigurationTests { + + @Test + public void propertiesTest() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.ollama.base-url=TEST_BASE_URL", + "spring.ai.ollama.chat.options.model=MODEL_XYZ", + "spring.ai.ollama.chat.options.temperature=0.55", + "spring.ai.ollama.chat.options.topP=0.56", + "spring.ai.ollama.chat.options.topK=123") + // @formatter:on + .withConfiguration(AutoConfigurations.of(RestClientAutoConfiguration.class, OllamaAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(OllamaChatProperties.class); + var connectionProperties = context.getBean(OllamaConnectionProperties.class); + + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(chatProperties.getModel()).isEqualTo("MODEL_XYZ"); + + assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55); + assertThat(chatProperties.getOptions().getTopP()).isEqualTo(0.56); + + assertThat(chatProperties.getOptions().getTopK()).isEqualTo(123); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/OllamaEmbeddingAutoConfigurationIT.java b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/OllamaEmbeddingAutoConfigurationIT.java new file mode 100644 index 00000000000..fc6fd6770de --- /dev/null +++ b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/OllamaEmbeddingAutoConfigurationIT.java @@ -0,0 +1,103 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.ollama; + +import java.io.IOException; +import java.util.List; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.ai.ollama.OllamaEmbeddingModel; +import org.springframework.ai.ollama.api.OllamaApi; +import org.springframework.ai.ollama.api.OllamaModel; +import org.springframework.ai.ollama.management.OllamaModelManager; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Christian Tzolov + * @author Thomas Vitale + * @since 1.0.0 + */ +public class OllamaEmbeddingAutoConfigurationIT extends BaseOllamaIT { + + private static final String MODEL_NAME = OllamaModel.NOMIC_EMBED_TEXT.getName(); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.ollama.embedding.options.model=" + MODEL_NAME, + "spring.ai.ollama.base-url=" + getBaseUrl()) + .withConfiguration(AutoConfigurations.of(RestClientAutoConfiguration.class, OllamaAutoConfiguration.class)); + + @BeforeAll + public static void beforeAll() throws IOException, InterruptedException { + initializeOllama(MODEL_NAME); + } + + @Test + public void singleTextEmbedding() { + this.contextRunner.run(context -> { + OllamaEmbeddingModel embeddingModel = context.getBean(OllamaEmbeddingModel.class); + assertThat(embeddingModel).isNotNull(); + EmbeddingResponse embeddingResponse = embeddingModel.embedForResponse(List.of("Hello World")); + assertThat(embeddingResponse.getResults()).hasSize(1); + assertThat(embeddingResponse.getResults().get(0).getOutput()).isNotEmpty(); + assertThat(embeddingModel.dimensions()).isEqualTo(768); + }); + } + + @Test + public void embeddingWithPull() { + this.contextRunner.withPropertyValues("spring.ai.ollama.init.pull-model-strategy=when_missing") + .withPropertyValues("spring.ai.ollama.embedding.options.model=all-minilm") + .run(context -> { + var model = "all-minilm"; + OllamaApi ollamaApi = context.getBean(OllamaApi.class); + var modelManager = new OllamaModelManager(ollamaApi); + assertThat(modelManager.isModelAvailable(model)).isTrue(); + + OllamaEmbeddingModel embeddingModel = context.getBean(OllamaEmbeddingModel.class); + EmbeddingResponse embeddingResponse = embeddingModel.embedForResponse(List.of("Hello World")); + assertThat(embeddingResponse.getResults().get(0).getOutput()).isNotEmpty(); + modelManager.deleteModel(model); + }); + } + + @Test + void embeddingActivation() { + this.contextRunner.withPropertyValues("spring.ai.ollama.embedding.enabled=false").run(context -> { + assertThat(context.getBeansOfType(OllamaEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OllamaEmbeddingModel.class)).isEmpty(); + }); + + this.contextRunner.run(context -> { + assertThat(context.getBeansOfType(OllamaEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OllamaEmbeddingModel.class)).isNotEmpty(); + }); + + this.contextRunner.withPropertyValues("spring.ai.ollama.embedding.enabled=true").run(context -> { + assertThat(context.getBeansOfType(OllamaEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OllamaEmbeddingModel.class)).isNotEmpty(); + }); + + } + +} diff --git a/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/OllamaEmbeddingAutoConfigurationTests.java b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/OllamaEmbeddingAutoConfigurationTests.java new file mode 100644 index 00000000000..bd2a8bfd2df --- /dev/null +++ b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/OllamaEmbeddingAutoConfigurationTests.java @@ -0,0 +1,57 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.ollama; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Christian Tzolov + * @since 0.8.0 + */ +public class OllamaEmbeddingAutoConfigurationTests { + + @Test + public void propertiesTest() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.ollama.base-url=TEST_BASE_URL", + "spring.ai.ollama.embedding.options.model=MODEL_XYZ", + "spring.ai.ollama.embedding.options.temperature=0.13", + "spring.ai.ollama.embedding.options.topK=13" + // @formatter:on + ) + .withConfiguration(AutoConfigurations.of(RestClientAutoConfiguration.class, OllamaAutoConfiguration.class)) + .run(context -> { + var embeddingProperties = context.getBean(OllamaEmbeddingProperties.class); + var connectionProperties = context.getBean(OllamaConnectionProperties.class); + + assertThat(embeddingProperties.getModel()).isEqualTo("MODEL_XYZ"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + assertThat(embeddingProperties.getOptions().toMap()).containsKeys("temperature"); + assertThat(embeddingProperties.getOptions().toMap().get("temperature")).isEqualTo(0.13); + assertThat(embeddingProperties.getOptions().getTopK()).isEqualTo(13); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/OllamaImage.java b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/OllamaImage.java new file mode 100644 index 00000000000..807975a49f7 --- /dev/null +++ b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/OllamaImage.java @@ -0,0 +1,27 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.ollama; + +public final class OllamaImage { + + public static final String DEFAULT_IMAGE = "ollama/ollama:0.5.7"; + + private OllamaImage() { + + } + +} diff --git a/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/tool/FunctionCallbackInPromptIT.java b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/tool/FunctionCallbackInPromptIT.java new file mode 100644 index 00000000000..a703033a128 --- /dev/null +++ b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/tool/FunctionCallbackInPromptIT.java @@ -0,0 +1,125 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.ollama.tool; + +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; + +import org.springframework.ai.autoconfigure.ollama.BaseOllamaIT; +import org.springframework.ai.autoconfigure.ollama.OllamaAutoConfiguration; +import org.springframework.ai.chat.messages.AssistantMessage; +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.Prompt; +import org.springframework.ai.ollama.OllamaChatModel; +import org.springframework.ai.ollama.api.OllamaOptions; +import org.springframework.ai.tool.function.FunctionToolCallback; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +public class FunctionCallbackInPromptIT extends BaseOllamaIT { + + private static final Logger logger = LoggerFactory.getLogger(FunctionCallbackInPromptIT.class); + + private static final String MODEL_NAME = "qwen2.5:3b"; + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.ollama.baseUrl=" + getBaseUrl(), + "spring.ai.ollama.chat.options.model=" + MODEL_NAME, + "spring.ai.ollama.chat.options.temperature=0.5", + "spring.ai.ollama.chat.options.topK=10") + // @formatter:on + .withConfiguration(AutoConfigurations.of(OllamaAutoConfiguration.class)); + + @BeforeAll + public static void beforeAll() { + initializeOllama(MODEL_NAME); + } + + @Test + void functionCallTest() { + this.contextRunner.run(context -> { + + OllamaChatModel chatModel = context.getBean(OllamaChatModel.class); + + UserMessage userMessage = new UserMessage( + "What are the weather conditions in San Francisco, Tokyo, and Paris? Find the temperature in Celsius for each of the three locations."); + + var promptOptions = OllamaOptions.builder() + .functionCallbacks(List.of(FunctionToolCallback + .builder("CurrentWeatherService", new MockWeatherService()) + .description( + "Find the weather conditions, forecasts, and temperatures for a location, like a city or state.") + .inputType(MockWeatherService.Request.class) + .build())) + .build(); + + ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), promptOptions)); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + }); + } + + @Test + void streamingFunctionCallTest() { + this.contextRunner.run(context -> { + + OllamaChatModel chatModel = context.getBean(OllamaChatModel.class); + + UserMessage userMessage = new UserMessage( + "What are the weather conditions in San Francisco, Tokyo, and Paris? Find the temperature in Celsius for each of the three locations."); + + var promptOptions = OllamaOptions.builder() + .functionCallbacks(List.of(FunctionToolCallback + .builder("CurrentWeatherService", new MockWeatherService()) + .description( + "Find the weather conditions, forecasts, and temperatures for a location, like a city or state.") + .inputType(MockWeatherService.Request.class) + .build())) + .build(); + + Flux response = chatModel.stream(new Prompt(List.of(userMessage), 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("30.0", "30"); + assertThat(content).containsAnyOf("10.0", "10"); + assertThat(content).containsAnyOf("15.0", "15"); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/tool/MockWeatherService.java b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/tool/MockWeatherService.java new file mode 100644 index 00000000000..88154dd3e73 --- /dev/null +++ b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/tool/MockWeatherService.java @@ -0,0 +1,97 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.ollama.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; + +/** + * Mock 3rd party weather service. + * + * @author Christian Tzolov + */ +public class MockWeatherService implements Function { + + @Override + public Response apply(Request request) { + + double temperature = 10; + 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 = "lat") @JsonPropertyDescription("The city latitude") double lat, + @JsonProperty(required = true, value = "lon") @JsonPropertyDescription("The city longitude") double lon, + @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/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/tool/OllamaFunctionCallbackIT.java b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/tool/OllamaFunctionCallbackIT.java new file mode 100644 index 00000000000..3e739c8b60f --- /dev/null +++ b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/tool/OllamaFunctionCallbackIT.java @@ -0,0 +1,147 @@ +/* + * 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.autoconfigure.ollama.tool; + +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; + +import org.springframework.ai.autoconfigure.ollama.BaseOllamaIT; +import org.springframework.ai.autoconfigure.ollama.OllamaAutoConfiguration; +import org.springframework.ai.chat.messages.AssistantMessage; +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.Prompt; +import org.springframework.ai.model.tool.ToolCallingChatOptions; +import org.springframework.ai.ollama.OllamaChatModel; +import org.springframework.ai.ollama.api.OllamaOptions; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.function.FunctionToolCallback; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +public class OllamaFunctionCallbackIT extends BaseOllamaIT { + + private static final Logger logger = LoggerFactory.getLogger(OllamaFunctionCallbackIT.class); + + private static final String MODEL_NAME = "qwen2.5:3b"; + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.ollama.baseUrl=" + getBaseUrl(), + "spring.ai.ollama.chat.options.model=" + MODEL_NAME, + "spring.ai.ollama.chat.options.temperature=0.5", + "spring.ai.ollama.chat.options.topK=10") + // @formatter:on + .withConfiguration(AutoConfigurations.of(OllamaAutoConfiguration.class)) + .withUserConfiguration(Config.class); + + @BeforeAll + public static void beforeAll() { + initializeOllama(MODEL_NAME); + } + + @Test + void functionCallTest() { + this.contextRunner.run(context -> { + + OllamaChatModel chatModel = context.getBean(OllamaChatModel.class); + + UserMessage userMessage = new UserMessage( + "What are the weather conditions in San Francisco, Tokyo, and Paris? Find the temperature in Celsius for each of the three locations."); + + ChatResponse response = chatModel + .call(new Prompt(List.of(userMessage), OllamaOptions.builder().function("WeatherInfo").build())); + + logger.info("Response: " + response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + }); + } + + @Test + void streamFunctionCallTest() { + this.contextRunner.run(context -> { + + OllamaChatModel chatModel = context.getBean(OllamaChatModel.class); + + UserMessage userMessage = new UserMessage( + "What are the weather conditions in San Francisco, Tokyo, and Paris? Find the temperature in Celsius for each of the three locations."); + + Flux response = chatModel + .stream(new Prompt(List.of(userMessage), OllamaOptions.builder().function("WeatherInfo").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).contains("30", "10", "15"); + }); + } + + @Test + void functionCallWithPortableFunctionCallingOptions() { + this.contextRunner.run(context -> { + + OllamaChatModel chatModel = context.getBean(OllamaChatModel.class); + + // Test weatherFunction + UserMessage userMessage = new UserMessage( + "What are the weather conditions in San Francisco, Tokyo, and Paris? Find the temperature in Celsius for each of the three locations."); + + ToolCallingChatOptions functionOptions = ToolCallingChatOptions.builder().toolNames("WeatherInfo").build(); + + ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), functionOptions)); + + logger.info("Response: " + response.getResult().getOutput().getText()); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + }); + } + + @Configuration + static class Config { + + @Bean + public ToolCallback weatherFunctionInfo() { + + return FunctionToolCallback.builder("WeatherInfo", new MockWeatherService()) + .description( + "Find the weather conditions, forecasts, and temperatures for a location, like a city or state.") + .inputType(MockWeatherService.Request.class) + .build(); + } + + } + +} diff --git a/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/tool/OllamaFunctionToolBeanIT.java b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/tool/OllamaFunctionToolBeanIT.java new file mode 100644 index 00000000000..6b6380f74a7 --- /dev/null +++ b/auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/tool/OllamaFunctionToolBeanIT.java @@ -0,0 +1,195 @@ +/* + * 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.autoconfigure.ollama.tool; + +import java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; + +import org.springframework.ai.autoconfigure.ollama.BaseOllamaIT; +import org.springframework.ai.autoconfigure.ollama.OllamaAutoConfiguration; +import org.springframework.ai.chat.messages.AssistantMessage; +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.Prompt; +import org.springframework.ai.model.tool.ToolCallingChatOptions; +import org.springframework.ai.ollama.OllamaChatModel; +import org.springframework.ai.ollama.api.OllamaOptions; +import org.springframework.ai.tool.ToolCallbacks; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Description; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for function-based tool calling in Ollama. + * + * @author Thomas Vitale + */ +public class OllamaFunctionToolBeanIT extends BaseOllamaIT { + + private static final Logger logger = LoggerFactory.getLogger(OllamaFunctionToolBeanIT.class); + + private static final String MODEL_NAME = "qwen2.5:3b"; + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.ollama.baseUrl=" + getBaseUrl(), + "spring.ai.ollama.chat.options.model=" + MODEL_NAME, + "spring.ai.ollama.chat.options.temperature=0.5", + "spring.ai.ollama.chat.options.topK=10") + // @formatter:on + .withConfiguration(AutoConfigurations.of(OllamaAutoConfiguration.class)) + .withUserConfiguration(Config.class); + + @BeforeAll + public static void beforeAll() { + initializeOllama(MODEL_NAME); + } + + @Test + void toolCallTest() { + this.contextRunner.run(context -> { + + OllamaChatModel chatModel = context.getBean(OllamaChatModel.class); + + MyTools myTools = context.getBean(MyTools.class); + + UserMessage userMessage = new UserMessage( + "What are the weather conditions in San Francisco, Tokyo, and Paris? Find the temperature in Celsius for each of the three locations."); + + ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), + OllamaOptions.builder().toolCallbacks(ToolCallbacks.from(myTools)).build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + }); + + } + + @Test + void functionCallTest() { + this.contextRunner.run(context -> { + + OllamaChatModel chatModel = context.getBean(OllamaChatModel.class); + + UserMessage userMessage = new UserMessage( + "What are the weather conditions in San Francisco, Tokyo, and Paris? Find the temperature in Celsius for each of the three locations."); + + ChatResponse response = chatModel + .call(new Prompt(List.of(userMessage), OllamaOptions.builder().toolNames("weatherInfo").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + }); + } + + @Test + void streamFunctionCallTest() { + this.contextRunner.run(context -> { + + OllamaChatModel chatModel = context.getBean(OllamaChatModel.class); + + UserMessage userMessage = new UserMessage( + "What are the weather conditions in San Francisco, Tokyo, and Paris? Find the temperature in Celsius for each of the three locations."); + + Flux response = chatModel + .stream(new Prompt(List.of(userMessage), OllamaOptions.builder().function("weatherInfo").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).contains("30", "10", "15"); + }); + } + + @Test + void functionCallWithPortableFunctionCallingOptions() { + this.contextRunner.run(context -> { + + OllamaChatModel chatModel = context.getBean(OllamaChatModel.class); + + // Test weatherFunction + UserMessage userMessage = new UserMessage( + "What are the weather conditions in San Francisco, Tokyo, and Paris? Find the temperature in Celsius for each of the three locations."); + + ToolCallingChatOptions functionOptions = ToolCallingChatOptions.builder().toolNames("weatherInfo").build(); + + ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), functionOptions)); + + logger.info("Response: {}", response.getResult().getOutput().getText()); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + }); + } + + static class MyTools { + + @Tool(description = "Find the weather conditions, and temperatures for a location, like a city or state.") + public String weatherByLocation(String locationName) { + int temperature = 0; + if (locationName.equals("San Francisco")) { + temperature = 30; + } + else if (locationName.equals("Tokyo")) { + temperature = 10; + } + else if (locationName.equals("Paris")) { + temperature = 15; + } + return "The temperature in " + locationName + " is " + temperature + " degrees Celsius."; + } + + } + + @Configuration + static class Config { + + @Bean + @Description("Find the weather conditions, forecasts, and temperatures for a location, like a city or state.") + public Function weatherInfo() { + return new MockWeatherService(); + } + + @Bean + public MyTools myTools() { + return new MyTools(); + } + + } + +} diff --git a/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/pom.xml b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/pom.xml new file mode 100644 index 00000000000..84a91e65b5e --- /dev/null +++ b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/pom.xml @@ -0,0 +1,101 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../../pom.xml + + spring-ai-openai-spring-boot-autoconfigure + jar + Spring AI OpenAI Auto Configuration + Spring AI OpenAI 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-openai + ${project.parent.version} + true + + + + + + org.springframework.ai + spring-ai-chat-client-spring-boot-autoconfigure + ${project.parent.version} + true + + + + org.springframework.ai + spring-ai-chat-model-spring-boot-autoconfigure + ${project.parent.version} + true + + + + org.springframework.ai + spring-ai-retry-spring-boot-autoconfigure + ${project.parent.version} + true + + + + + org.springframework.boot + spring-boot-starter + true + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + + + org.jetbrains.kotlin + kotlin-reflect + 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-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAudioSpeechProperties.java b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAudioSpeechProperties.java new file mode 100644 index 00000000000..8428607ea81 --- /dev/null +++ b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAudioSpeechProperties.java @@ -0,0 +1,75 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.openai; + +import org.springframework.ai.openai.OpenAiAudioSpeechOptions; +import org.springframework.ai.openai.api.OpenAiAudioApi; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * Configuration properties for OpenAI audio speech. + * + * Default values for required options are model = tts_1, response format = mp3, voice = + * alloy, and speed = 1. + * + * @author Ahmed Yousri + * @author Stefan Vassilev + */ +@ConfigurationProperties(OpenAiAudioSpeechProperties.CONFIG_PREFIX) +public class OpenAiAudioSpeechProperties extends OpenAiParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.openai.audio.speech"; + + public static final String DEFAULT_SPEECH_MODEL = OpenAiAudioApi.TtsModel.TTS_1.getValue(); + + private static final Float SPEED = 1.0f; + + private static final OpenAiAudioApi.SpeechRequest.Voice VOICE = OpenAiAudioApi.SpeechRequest.Voice.ALLOY; + + private static final OpenAiAudioApi.SpeechRequest.AudioResponseFormat DEFAULT_RESPONSE_FORMAT = OpenAiAudioApi.SpeechRequest.AudioResponseFormat.MP3; + + /** + * Enable OpenAI audio speech model. + */ + private boolean enabled = true; + + @NestedConfigurationProperty + private OpenAiAudioSpeechOptions options = OpenAiAudioSpeechOptions.builder() + .model(DEFAULT_SPEECH_MODEL) + .responseFormat(DEFAULT_RESPONSE_FORMAT) + .voice(VOICE) + .speed(SPEED) + .build(); + + public OpenAiAudioSpeechOptions getOptions() { + return this.options; + } + + public void setOptions(OpenAiAudioSpeechOptions options) { + this.options = options; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + +} diff --git a/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAudioTranscriptionProperties.java b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAudioTranscriptionProperties.java new file mode 100644 index 00000000000..63a1c9870e5 --- /dev/null +++ b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAudioTranscriptionProperties.java @@ -0,0 +1,63 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.openai; + +import org.springframework.ai.openai.OpenAiAudioTranscriptionOptions; +import org.springframework.ai.openai.api.OpenAiAudioApi; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +@ConfigurationProperties(OpenAiAudioTranscriptionProperties.CONFIG_PREFIX) +public class OpenAiAudioTranscriptionProperties extends OpenAiParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.openai.audio.transcription"; + + public static final String DEFAULT_TRANSCRIPTION_MODEL = OpenAiAudioApi.WhisperModel.WHISPER_1.getValue(); + + private static final Double DEFAULT_TEMPERATURE = 0.7; + + private static final OpenAiAudioApi.TranscriptResponseFormat DEFAULT_RESPONSE_FORMAT = OpenAiAudioApi.TranscriptResponseFormat.TEXT; + + /** + * Enable OpenAI audio transcription model. + */ + private boolean enabled = true; + + @NestedConfigurationProperty + private OpenAiAudioTranscriptionOptions options = OpenAiAudioTranscriptionOptions.builder() + .model(DEFAULT_TRANSCRIPTION_MODEL) + .temperature(DEFAULT_TEMPERATURE.floatValue()) + .responseFormat(DEFAULT_RESPONSE_FORMAT) + .build(); + + public OpenAiAudioTranscriptionOptions getOptions() { + return this.options; + } + + public void setOptions(OpenAiAudioTranscriptionOptions options) { + this.options = options; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + +} diff --git a/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfiguration.java b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfiguration.java new file mode 100644 index 00000000000..af934cd70cb --- /dev/null +++ b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfiguration.java @@ -0,0 +1,310 @@ +/* + * 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.autoconfigure.openai; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import io.micrometer.observation.ObservationRegistry; +import org.jetbrains.annotations.NotNull; + +import org.springframework.ai.autoconfigure.chat.model.ToolCallingAutoConfiguration; +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.chat.observation.ChatModelObservationConvention; +import org.springframework.ai.embedding.observation.EmbeddingModelObservationConvention; +import org.springframework.ai.image.observation.ImageModelObservationConvention; +import org.springframework.ai.model.SimpleApiKey; +import org.springframework.ai.model.function.DefaultFunctionCallbackResolver; +import org.springframework.ai.model.function.FunctionCallbackResolver; +import org.springframework.ai.model.tool.ToolCallingManager; +import org.springframework.ai.openai.OpenAiAudioSpeechModel; +import org.springframework.ai.openai.OpenAiAudioTranscriptionModel; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiEmbeddingModel; +import org.springframework.ai.openai.OpenAiImageModel; +import org.springframework.ai.openai.OpenAiModerationModel; +import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.ai.openai.api.OpenAiAudioApi; +import org.springframework.ai.openai.api.OpenAiImageApi; +import org.springframework.ai.openai.api.OpenAiModerationApi; +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.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.MultiValueMap; +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; + +/** + * {@link AutoConfiguration Auto-configuration} for OpenAI. + * + * @author Christian Tzolov + * @author Stefan Vassilev + * @author Thomas Vitale + * @author Ilayaperumal Gopinathan + */ +@AutoConfiguration(after = { RestClientAutoConfiguration.class, WebClientAutoConfiguration.class, + SpringAiRetryAutoConfiguration.class, ToolCallingAutoConfiguration.class }) +@ConditionalOnClass(OpenAiApi.class) +@EnableConfigurationProperties({ OpenAiConnectionProperties.class, OpenAiChatProperties.class, + OpenAiEmbeddingProperties.class, OpenAiImageProperties.class, OpenAiAudioTranscriptionProperties.class, + OpenAiAudioSpeechProperties.class, OpenAiModerationProperties.class }) +@ImportAutoConfiguration(classes = { SpringAiRetryAutoConfiguration.class, RestClientAutoConfiguration.class, + WebClientAutoConfiguration.class, ToolCallingAutoConfiguration.class }) +public class OpenAiAutoConfiguration { + + private static @NotNull ResolvedConnectionProperties resolveConnectionProperties( + OpenAiParentProperties commonProperties, OpenAiParentProperties modelProperties, String modelType) { + + String baseUrl = StringUtils.hasText(modelProperties.getBaseUrl()) ? modelProperties.getBaseUrl() + : commonProperties.getBaseUrl(); + String apiKey = StringUtils.hasText(modelProperties.getApiKey()) ? modelProperties.getApiKey() + : commonProperties.getApiKey(); + String projectId = StringUtils.hasText(modelProperties.getProjectId()) ? modelProperties.getProjectId() + : commonProperties.getProjectId(); + String organizationId = StringUtils.hasText(modelProperties.getOrganizationId()) + ? modelProperties.getOrganizationId() : commonProperties.getOrganizationId(); + + Map> connectionHeaders = new HashMap<>(); + if (StringUtils.hasText(projectId)) { + connectionHeaders.put("OpenAI-Project", List.of(projectId)); + } + if (StringUtils.hasText(organizationId)) { + connectionHeaders.put("OpenAI-Organization", List.of(organizationId)); + } + + Assert.hasText(baseUrl, + "OpenAI base URL must be set. Use the connection property: spring.ai.openai.base-url or spring.ai.openai." + + modelType + ".base-url property."); + Assert.hasText(apiKey, + "OpenAI API key must be set. Use the connection property: spring.ai.openai.api-key or spring.ai.openai." + + modelType + ".api-key property."); + + return new ResolvedConnectionProperties(baseUrl, apiKey, CollectionUtils.toMultiValueMap(connectionHeaders)); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = OpenAiChatProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + public OpenAiChatModel openAiChatModel(OpenAiConnectionProperties commonProperties, + OpenAiChatProperties chatProperties, ObjectProvider restClientBuilderProvider, + ObjectProvider webClientBuilderProvider, ToolCallingManager toolCallingManager, + RetryTemplate retryTemplate, ResponseErrorHandler responseErrorHandler, + ObjectProvider observationRegistry, + ObjectProvider observationConvention) { + + var openAiApi = openAiApi(chatProperties, commonProperties, + restClientBuilderProvider.getIfAvailable(RestClient::builder), + webClientBuilderProvider.getIfAvailable(WebClient::builder), responseErrorHandler, "chat"); + + var chatModel = OpenAiChatModel.builder() + .openAiApi(openAiApi) + .defaultOptions(chatProperties.getOptions()) + .toolCallingManager(toolCallingManager) + .retryTemplate(retryTemplate) + .observationRegistry(observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)) + .build(); + + observationConvention.ifAvailable(chatModel::setObservationConvention); + + return chatModel; + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = OpenAiEmbeddingProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + public OpenAiEmbeddingModel openAiEmbeddingModel(OpenAiConnectionProperties commonProperties, + OpenAiEmbeddingProperties embeddingProperties, ObjectProvider restClientBuilderProvider, + ObjectProvider webClientBuilderProvider, RetryTemplate retryTemplate, + ResponseErrorHandler responseErrorHandler, ObjectProvider observationRegistry, + ObjectProvider observationConvention) { + + var openAiApi = openAiApi(embeddingProperties, commonProperties, + restClientBuilderProvider.getIfAvailable(RestClient::builder), + webClientBuilderProvider.getIfAvailable(WebClient::builder), responseErrorHandler, "embedding"); + + var embeddingModel = new OpenAiEmbeddingModel(openAiApi, embeddingProperties.getMetadataMode(), + embeddingProperties.getOptions(), retryTemplate, + observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)); + + observationConvention.ifAvailable(embeddingModel::setObservationConvention); + + return embeddingModel; + } + + private OpenAiApi openAiApi(OpenAiChatProperties chatProperties, OpenAiConnectionProperties commonProperties, + RestClient.Builder restClientBuilder, WebClient.Builder webClientBuilder, + ResponseErrorHandler responseErrorHandler, String modelType) { + + ResolvedConnectionProperties resolved = resolveConnectionProperties(commonProperties, chatProperties, + modelType); + + return OpenAiApi.builder() + .baseUrl(resolved.baseUrl()) + .apiKey(new SimpleApiKey(resolved.apiKey())) + .headers(resolved.headers()) + .completionsPath(chatProperties.getCompletionsPath()) + .embeddingsPath(OpenAiEmbeddingProperties.DEFAULT_EMBEDDINGS_PATH) + .restClientBuilder(restClientBuilder) + .webClientBuilder(webClientBuilder) + .responseErrorHandler(responseErrorHandler) + .build(); + } + + private OpenAiApi openAiApi(OpenAiEmbeddingProperties embeddingProperties, + OpenAiConnectionProperties commonProperties, RestClient.Builder restClientBuilder, + WebClient.Builder webClientBuilder, ResponseErrorHandler responseErrorHandler, String modelType) { + + ResolvedConnectionProperties resolved = resolveConnectionProperties(commonProperties, embeddingProperties, + modelType); + + return OpenAiApi.builder() + .baseUrl(resolved.baseUrl()) + .apiKey(new SimpleApiKey(resolved.apiKey())) + .headers(resolved.headers()) + .completionsPath(OpenAiChatProperties.DEFAULT_COMPLETIONS_PATH) + .embeddingsPath(embeddingProperties.getEmbeddingsPath()) + .restClientBuilder(restClientBuilder) + .webClientBuilder(webClientBuilder) + .responseErrorHandler(responseErrorHandler) + .build(); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = OpenAiImageProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + public OpenAiImageModel openAiImageModel(OpenAiConnectionProperties commonProperties, + OpenAiImageProperties imageProperties, ObjectProvider restClientBuilderProvider, + RetryTemplate retryTemplate, ResponseErrorHandler responseErrorHandler, + ObjectProvider observationRegistry, + ObjectProvider observationConvention) { + + ResolvedConnectionProperties resolved = resolveConnectionProperties(commonProperties, imageProperties, "image"); + + var openAiImageApi = OpenAiImageApi.builder() + .baseUrl(resolved.baseUrl()) + .apiKey(new SimpleApiKey(resolved.apiKey())) + .headers(resolved.headers()) + .restClientBuilder(restClientBuilderProvider.getIfAvailable(RestClient::builder)) + .responseErrorHandler(responseErrorHandler) + .build(); + var imageModel = new OpenAiImageModel(openAiImageApi, imageProperties.getOptions(), retryTemplate, + observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)); + + observationConvention.ifAvailable(imageModel::setObservationConvention); + + return imageModel; + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = OpenAiAudioTranscriptionProperties.CONFIG_PREFIX, name = "enabled", + havingValue = "true", matchIfMissing = true) + public OpenAiAudioTranscriptionModel openAiAudioTranscriptionModel(OpenAiConnectionProperties commonProperties, + OpenAiAudioTranscriptionProperties transcriptionProperties, RetryTemplate retryTemplate, + ObjectProvider restClientBuilderProvider, + ObjectProvider webClientBuilderProvider, ResponseErrorHandler responseErrorHandler) { + + ResolvedConnectionProperties resolved = resolveConnectionProperties(commonProperties, transcriptionProperties, + "transcription"); + + var openAiAudioApi = OpenAiAudioApi.builder() + .baseUrl(resolved.baseUrl()) + .apiKey(new SimpleApiKey(resolved.apiKey())) + .headers(resolved.headers()) + .restClientBuilder(restClientBuilderProvider.getIfAvailable(RestClient::builder)) + .webClientBuilder(webClientBuilderProvider.getIfAvailable(WebClient::builder)) + .responseErrorHandler(responseErrorHandler) + .build(); + + return new OpenAiAudioTranscriptionModel(openAiAudioApi, transcriptionProperties.getOptions(), retryTemplate); + + } + + @Bean + @ConditionalOnMissingBean + public OpenAiModerationModel openAiModerationClient(OpenAiConnectionProperties commonProperties, + OpenAiModerationProperties moderationProperties, RetryTemplate retryTemplate, + ObjectProvider restClientBuilderProvider, ResponseErrorHandler responseErrorHandler) { + + ResolvedConnectionProperties resolved = resolveConnectionProperties(commonProperties, moderationProperties, + "moderation"); + + var openAiModerationApi = OpenAiModerationApi.builder() + .baseUrl(resolved.baseUrl) + .apiKey(new SimpleApiKey(resolved.apiKey())) + .headers(resolved.headers()) + .restClientBuilder(restClientBuilderProvider.getIfAvailable(RestClient::builder)) + .responseErrorHandler(responseErrorHandler) + .build(); + return new OpenAiModerationModel(openAiModerationApi, retryTemplate) + .withDefaultOptions(moderationProperties.getOptions()); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = OpenAiAudioSpeechProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + public OpenAiAudioSpeechModel openAiAudioSpeechClient(OpenAiConnectionProperties commonProperties, + OpenAiAudioSpeechProperties speechProperties, RetryTemplate retryTemplate, + ObjectProvider restClientBuilderProvider, + ObjectProvider webClientBuilderProvider, ResponseErrorHandler responseErrorHandler) { + + ResolvedConnectionProperties resolved = resolveConnectionProperties(commonProperties, speechProperties, + "speach"); + + var openAiAudioApi = OpenAiAudioApi.builder() + .baseUrl(resolved.baseUrl()) + .apiKey(new SimpleApiKey(resolved.apiKey())) + .headers(resolved.headers()) + .restClientBuilder(restClientBuilderProvider.getIfAvailable(RestClient::builder)) + .webClientBuilder(webClientBuilderProvider.getIfAvailable(WebClient::builder)) + .responseErrorHandler(responseErrorHandler) + .build(); + + return new OpenAiAudioSpeechModel(openAiAudioApi, speechProperties.getOptions()); + } + + @Bean + @ConditionalOnMissingBean + public FunctionCallbackResolver springAiFunctionManager(ApplicationContext context) { + DefaultFunctionCallbackResolver manager = new DefaultFunctionCallbackResolver(); + manager.setApplicationContext(context); + return manager; + } + + private record ResolvedConnectionProperties(String baseUrl, String apiKey, MultiValueMap headers) { + + } + +} diff --git a/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiChatProperties.java b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiChatProperties.java new file mode 100644 index 00000000000..cf0ee1971a0 --- /dev/null +++ b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiChatProperties.java @@ -0,0 +1,71 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.openai; + +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +@ConfigurationProperties(OpenAiChatProperties.CONFIG_PREFIX) +public class OpenAiChatProperties extends OpenAiParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.openai.chat"; + + public static final String DEFAULT_CHAT_MODEL = "gpt-4o-mini"; + + public static final String DEFAULT_COMPLETIONS_PATH = "/v1/chat/completions"; + + private static final Double DEFAULT_TEMPERATURE = 0.7; + + /** + * Enable OpenAI chat model. + */ + private boolean enabled = true; + + private String completionsPath = DEFAULT_COMPLETIONS_PATH; + + @NestedConfigurationProperty + private OpenAiChatOptions options = OpenAiChatOptions.builder() + .model(DEFAULT_CHAT_MODEL) + .temperature(DEFAULT_TEMPERATURE) + .build(); + + public OpenAiChatOptions getOptions() { + return this.options; + } + + public void setOptions(OpenAiChatOptions options) { + this.options = options; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getCompletionsPath() { + return this.completionsPath; + } + + public void setCompletionsPath(String completionsPath) { + this.completionsPath = completionsPath; + } + +} diff --git a/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiConnectionProperties.java b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiConnectionProperties.java new file mode 100644 index 00000000000..e6c6f582d1b --- /dev/null +++ b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiConnectionProperties.java @@ -0,0 +1,32 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.openai; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(OpenAiConnectionProperties.CONFIG_PREFIX) +public class OpenAiConnectionProperties extends OpenAiParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.openai"; + + public static final String DEFAULT_BASE_URL = "https://api.openai.com"; + + public OpenAiConnectionProperties() { + super.setBaseUrl(DEFAULT_BASE_URL); + } + +} diff --git a/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiEmbeddingProperties.java b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiEmbeddingProperties.java new file mode 100644 index 00000000000..ea17041b5b3 --- /dev/null +++ b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiEmbeddingProperties.java @@ -0,0 +1,77 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.openai; + +import org.springframework.ai.document.MetadataMode; +import org.springframework.ai.openai.OpenAiEmbeddingOptions; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +@ConfigurationProperties(OpenAiEmbeddingProperties.CONFIG_PREFIX) +public class OpenAiEmbeddingProperties extends OpenAiParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.openai.embedding"; + + public static final String DEFAULT_EMBEDDING_MODEL = "text-embedding-ada-002"; + + public static final String DEFAULT_EMBEDDINGS_PATH = "/v1/embeddings"; + + /** + * Enable OpenAI embedding model. + */ + private boolean enabled = true; + + private MetadataMode metadataMode = MetadataMode.EMBED; + + private String embeddingsPath = DEFAULT_EMBEDDINGS_PATH; + + @NestedConfigurationProperty + private OpenAiEmbeddingOptions options = OpenAiEmbeddingOptions.builder().model(DEFAULT_EMBEDDING_MODEL).build(); + + public OpenAiEmbeddingOptions getOptions() { + return this.options; + } + + public void setOptions(OpenAiEmbeddingOptions options) { + this.options = options; + } + + public MetadataMode getMetadataMode() { + return this.metadataMode; + } + + public void setMetadataMode(MetadataMode metadataMode) { + this.metadataMode = metadataMode; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getEmbeddingsPath() { + return this.embeddingsPath; + } + + public void setEmbeddingsPath(String embeddingsPath) { + this.embeddingsPath = embeddingsPath; + } + +} diff --git a/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiImageProperties.java b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiImageProperties.java new file mode 100644 index 00000000000..b521e34ef17 --- /dev/null +++ b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiImageProperties.java @@ -0,0 +1,64 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.openai; + +import org.springframework.ai.openai.OpenAiImageOptions; +import org.springframework.ai.openai.api.OpenAiImageApi; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * OpenAI Image autoconfiguration properties. + * + * @author Thomas Vitale + * @since 0.8.0 + */ +@ConfigurationProperties(OpenAiImageProperties.CONFIG_PREFIX) +public class OpenAiImageProperties extends OpenAiParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.openai.image"; + + public static final String DEFAULT_IMAGE_MODEL = OpenAiImageApi.ImageModel.DALL_E_3.getValue(); + + /** + * Enable OpenAI image model. + */ + private boolean enabled = true; + + /** + * Options for OpenAI Image API. + */ + @NestedConfigurationProperty + private OpenAiImageOptions options = OpenAiImageOptions.builder().model(DEFAULT_IMAGE_MODEL).build(); + + public OpenAiImageOptions getOptions() { + return this.options; + } + + public void setOptions(OpenAiImageOptions options) { + this.options = options; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + +} diff --git a/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiModerationProperties.java b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiModerationProperties.java new file mode 100644 index 00000000000..d9e709862ef --- /dev/null +++ b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiModerationProperties.java @@ -0,0 +1,48 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.openai; + +import org.springframework.ai.openai.OpenAiModerationOptions; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * OpenAI Moderation autoconfiguration properties. + * + * @author Ahmed Yousri + * @since 0.9.0 + */ +@ConfigurationProperties(OpenAiModerationProperties.CONFIG_PREFIX) +public class OpenAiModerationProperties extends OpenAiParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.openai.moderation"; + + /** + * Options for OpenAI Moderation API. + */ + @NestedConfigurationProperty + private OpenAiModerationOptions options = OpenAiModerationOptions.builder().build(); + + public OpenAiModerationOptions getOptions() { + return this.options; + } + + public void setOptions(OpenAiModerationOptions options) { + this.options = options; + } + +} diff --git a/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiParentProperties.java b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiParentProperties.java new file mode 100644 index 00000000000..7516ba84460 --- /dev/null +++ b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiParentProperties.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.autoconfigure.openai; + +/** + * Internal parent properties for the OpenAI properties. + * + * @author Christian Tzolov + * @since 0.8.0 + */ +class OpenAiParentProperties { + + private String apiKey; + + private String baseUrl; + + private String projectId; + + private String organizationId; + + 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; + } + + public String getProjectId() { + return this.projectId; + } + + public void setProjectId(String projectId) { + this.projectId = projectId; + } + + public String getOrganizationId() { + return this.organizationId; + } + + public void setOrganizationId(String organizationId) { + this.organizationId = organizationId; + } + +} diff --git a/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 00000000000..17529588fd3 --- /dev/null +++ b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,22 @@ +{ + "groups": [ + { + "name": "spring.ai.openai.chat.output-audio", + "type": "org.springframework.ai.openai.api.OpenAiApi$ChatCompletionRequest$AudioParameters", + "sourceType": "org.springframework.ai.openai.OpenAiChatOptions" + } + ], + "properties": [ + { + "name": "spring.ai.openai.chat.output-audio.voice", + "type": "org.springframework.ai.openai.api.OpenAiApi$ChatCompletionRequest$AudioParameters$Voice", + "sourceType": "org.springframework.ai.openai.api.OpenAiApi$ChatCompletionRequest$AudioParameters" + }, + { + "name": "spring.ai.openai.chat.output-audio.format", + "type": "org.springframework.ai.openai.api.OpenAiApi$ChatCompletionRequest$AudioParameters$AudioResponseFormat", + "sourceType": "org.springframework.ai.openai.api.OpenAiApi$ChatCompletionRequest$AudioParameters" + } + ], + "hints": [] +} diff --git a/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..60b58e80808 --- /dev/null +++ b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/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.autoconfigure.anthropic.AnthropicAutoConfiguration diff --git a/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/ChatClientAutoConfigurationIT.java b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/ChatClientAutoConfigurationIT.java new file mode 100644 index 00000000000..55d2567ad17 --- /dev/null +++ b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/ChatClientAutoConfigurationIT.java @@ -0,0 +1,121 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.openai; + +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 org.springframework.ai.autoconfigure.chat.client.ChatClientAutoConfiguration; +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.ChatClientCustomizer; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Christian Tzolov + */ +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".*") +public class ChatClientAutoConfigurationIT { + + private static final Log logger = LogFactory.getLog(ChatClientAutoConfigurationIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.apiKey=" + System.getenv("OPENAI_API_KEY"), + "spring.ai.openai.chat.options.model=gpt-4o") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, OpenAiAutoConfiguration.class, ChatClientAutoConfiguration.class)); + + @Test + void implicitlyEnabled() { + this.contextRunner.run(context -> assertThat(context.getBeansOfType(ChatClient.Builder.class)).isNotEmpty()); + } + + @Test + void explicitlyEnabled() { + this.contextRunner.withPropertyValues("spring.ai.chat.client.enabled=true") + .run(context -> assertThat(context.getBeansOfType(ChatClient.Builder.class)).isNotEmpty()); + } + + @Test + void explicitlyDisabled() { + this.contextRunner.withPropertyValues("spring.ai.chat.client.enabled=false") + .run(context -> assertThat(context.getBeansOfType(ChatClient.Builder.class)).isEmpty()); + } + + @Test + void generate() { + this.contextRunner.run(context -> { + ChatClient.Builder builder = context.getBean(ChatClient.Builder.class); + + assertThat(builder).isNotNull(); + + ChatClient chatClient = builder.build(); + + String response = chatClient.prompt().user("Hello").call().content(); + + assertThat(response).isNotEmpty(); + logger.info("Response: " + response); + }); + } + + @Test + void testChatClientCustomizers() { + this.contextRunner.withUserConfiguration(Config.class).run(context -> { + + ChatClient.Builder builder = context.getBean(ChatClient.Builder.class); + + ChatClient chatClient = builder.build(); + + assertThat(chatClient).isNotNull(); + + ActorsFilms actorsFilms = chatClient.prompt() + .user(u -> u.param("actor", "Tom Hanks")) + .call() + .entity(ActorsFilms.class); + + logger.info("" + actorsFilms); + assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks"); + assertThat(actorsFilms.movies()).hasSize(5); + }); + } + + record ActorsFilms(String actor, List movies) { + + } + + @Configuration + static class Config { + + @Bean + public ChatClientCustomizer chatClientCustomizer() { + return b -> b.defaultSystem("You are a movie expert.") + .defaultUser("Generate the filmography of 5 movies for {actor}."); + } + + } + +} diff --git a/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfigurationIT.java b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfigurationIT.java new file mode 100644 index 00000000000..0ebe376bcaf --- /dev/null +++ b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfigurationIT.java @@ -0,0 +1,205 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.openai; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import reactor.core.publisher.Flux; + +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.metadata.Usage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.ai.image.ImagePrompt; +import org.springframework.ai.image.ImageResponse; +import org.springframework.ai.openai.OpenAiAudioSpeechModel; +import org.springframework.ai.openai.OpenAiAudioTranscriptionModel; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiEmbeddingModel; +import org.springframework.ai.openai.OpenAiImageModel; +import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; + +import static org.assertj.core.api.Assertions.assertThat; + +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".*") +public class OpenAiAutoConfigurationIT { + + private static final Log logger = LogFactory.getLog(OpenAiAutoConfigurationIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.apiKey=" + System.getenv("OPENAI_API_KEY")) + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)); + + @Test + void chatCall() { + this.contextRunner.run(context -> { + OpenAiChatModel chatModel = context.getBean(OpenAiChatModel.class); + String response = chatModel.call("Hello"); + assertThat(response).isNotEmpty(); + logger.info("Response: " + response); + }); + } + + @Test + void chatCallAudioResponse() { + this.contextRunner + .withPropertyValues( + "spring.ai.openai.chat.options.model=" + OpenAiApi.ChatModel.GPT_4_O_AUDIO_PREVIEW.getValue(), + "spring.ai.openai.chat.options.output-modalities=text,audio", + "spring.ai.openai.chat.options.output-audio.voice=ALLOY", + "spring.ai.openai.chat.options.output-audio.format=WAV") + .run(context -> { + OpenAiChatModel chatModel = context.getBean(OpenAiChatModel.class); + + ChatResponse response = chatModel + .call(new Prompt(new UserMessage("Tell me joke about Spring Framework"))); + assertThat(response).isNotNull(); + logger.info("Response: " + response); + // AudioPlayer.play(response.getResult().getOutput().getMedia().get(0).getDataAsByteArray()); + }); + } + + @Test + void transcribe() { + this.contextRunner.run(context -> { + OpenAiAudioTranscriptionModel transcriptionModel = context.getBean(OpenAiAudioTranscriptionModel.class); + Resource audioFile = new ClassPathResource("/speech/jfk.flac"); + String response = transcriptionModel.call(audioFile); + assertThat(response).isNotEmpty(); + logger.info("Response: " + response); + }); + } + + @Test + void speech() { + this.contextRunner.run(context -> { + OpenAiAudioSpeechModel speechModel = context.getBean(OpenAiAudioSpeechModel.class); + byte[] response = speechModel.call("H"); + assertThat(response).isNotNull(); + assertThat(verifyMp3FrameHeader(response)) + .withFailMessage("Expected MP3 frame header to be present in the response, but it was not found.") + .isTrue(); + assertThat(response.length).isNotEqualTo(0); + + logger.debug("Response: " + Arrays.toString(response)); + }); + } + + public boolean verifyMp3FrameHeader(byte[] audioResponse) { + // Check if the response is null or too short to contain a frame header + if (audioResponse == null || audioResponse.length < 2) { + return false; + } + // Check for the MP3 frame header + // 0xFFE0 is the sync word for an MP3 frame (11 bits set to 1 followed by 3 bits + // set to 0) + return (audioResponse[0] & 0xFF) == 0xFF && (audioResponse[1] & 0xE0) == 0xE0; + } + + @Test + void generateStreaming() { + this.contextRunner.run(context -> { + OpenAiChatModel chatModel = context.getBean(OpenAiChatModel.class); + Flux responseFlux = chatModel.stream(new Prompt(new UserMessage("Hello"))); + String response = responseFlux.collectList() + .block() + .stream() + .map(chatResponse -> chatResponse.getResults().get(0).getOutput().getText()) + .collect(Collectors.joining()); + + assertThat(response).isNotEmpty(); + logger.info("Response: " + response); + }); + } + + @Test + void streamingWithTokenUsage() { + this.contextRunner.withPropertyValues("spring.ai.openai.chat.options.stream-usage=true").run(context -> { + OpenAiChatModel chatModel = context.getBean(OpenAiChatModel.class); + + Flux responseFlux = chatModel.stream(new Prompt(new UserMessage("Hello"))); + + Usage[] streamingTokenUsage = new Usage[1]; + String response = responseFlux.collectList().block().stream().map(chatResponse -> { + streamingTokenUsage[0] = chatResponse.getMetadata().getUsage(); + return (chatResponse.getResult() != null) ? chatResponse.getResult().getOutput().getText() : ""; + }).collect(Collectors.joining()); + + assertThat(streamingTokenUsage[0].getPromptTokens()).isGreaterThan(0); + assertThat(streamingTokenUsage[0].getCompletionTokens()).isGreaterThan(0); + assertThat(streamingTokenUsage[0].getTotalTokens()).isGreaterThan(0); + + assertThat(response).isNotEmpty(); + logger.info("Response: " + response); + }); + } + + @Test + void embedding() { + this.contextRunner.run(context -> { + OpenAiEmbeddingModel embeddingModel = context.getBean(OpenAiEmbeddingModel.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); + }); + } + + @Test + void generateImage() { + this.contextRunner.withPropertyValues("spring.ai.openai.image.options.size=1024x1024").run(context -> { + OpenAiImageModel imageModel = context.getBean(OpenAiImageModel.class); + ImageResponse imageResponse = imageModel.call(new ImagePrompt("forest")); + assertThat(imageResponse.getResults()).hasSize(1); + assertThat(imageResponse.getResult().getOutput().getUrl()).isNotEmpty(); + logger.info("Generated image: " + imageResponse.getResult().getOutput().getUrl()); + }); + } + + @Test + void generateImageWithModel() { + // The 256x256 size is supported by dall-e-2, but not by dall-e-3. + this.contextRunner + .withPropertyValues("spring.ai.openai.image.options.model=dall-e-2", + "spring.ai.openai.image.options.size=256x256") + .run(context -> { + OpenAiImageModel imageModel = context.getBean(OpenAiImageModel.class); + ImageResponse imageResponse = imageModel.call(new ImagePrompt("forest")); + assertThat(imageResponse.getResults()).hasSize(1); + assertThat(imageResponse.getResult().getOutput().getUrl()).isNotEmpty(); + logger.info("Generated image: " + imageResponse.getResult().getOutput().getUrl()); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/OpenAiPropertiesTests.java b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/OpenAiPropertiesTests.java new file mode 100644 index 00000000000..fdbc0252444 --- /dev/null +++ b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/OpenAiPropertiesTests.java @@ -0,0 +1,687 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.openai; + +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; +import org.skyscreamer.jsonassert.JSONCompareMode; + +import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.openai.OpenAiAudioSpeechModel; +import org.springframework.ai.openai.OpenAiAudioTranscriptionModel; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiEmbeddingModel; +import org.springframework.ai.openai.OpenAiImageModel; +import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest.ToolChoiceBuilder; +import org.springframework.ai.openai.api.OpenAiAudioApi; +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 {@link OpenAiConnectionProperties}, {@link OpenAiChatProperties} and + * {@link OpenAiEmbeddingProperties}. + * + * @author Christian Tzolov + * @author Thomas Vitale + * @since 0.8.0 + */ +public class OpenAiPropertiesTests { + + @Test + public void chatProperties() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.openai.base-url=TEST_BASE_URL", + "spring.ai.openai.api-key=abc123", + "spring.ai.openai.chat.options.model=MODEL_XYZ", + "spring.ai.openai.chat.options.temperature=0.55") + // @formatter:on + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(OpenAiChatProperties.class); + var connectionProperties = context.getBean(OpenAiConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(chatProperties.getApiKey()).isNull(); + assertThat(chatProperties.getBaseUrl()).isNull(); + + assertThat(chatProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55); + }); + } + + @Test + public void transcriptionProperties() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.openai.base-url=TEST_BASE_URL", + "spring.ai.openai.api-key=abc123", + "spring.ai.openai.audio.transcription.options.model=MODEL_XYZ", + "spring.ai.openai.audio.transcription.options.temperature=0.55") + // @formatter:on + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + var transcriptionProperties = context.getBean(OpenAiAudioTranscriptionProperties.class); + var connectionProperties = context.getBean(OpenAiConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(transcriptionProperties.getApiKey()).isNull(); + assertThat(transcriptionProperties.getBaseUrl()).isNull(); + + assertThat(transcriptionProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(transcriptionProperties.getOptions().getTemperature()).isEqualTo(0.55f); + }); + } + + @Test + public void chatOverrideConnectionProperties() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.openai.base-url=TEST_BASE_URL", + "spring.ai.openai.api-key=abc123", + "spring.ai.openai.chat.base-url=TEST_BASE_URL2", + "spring.ai.openai.chat.api-key=456", + "spring.ai.openai.chat.options.model=MODEL_XYZ", + "spring.ai.openai.chat.options.temperature=0.55") + // @formatter:on + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(OpenAiChatProperties.class); + var connectionProperties = context.getBean(OpenAiConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(chatProperties.getApiKey()).isEqualTo("456"); + assertThat(chatProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL2"); + + assertThat(chatProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55); + }); + } + + @Test + public void transcriptionOverrideConnectionProperties() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.openai.base-url=TEST_BASE_URL", + "spring.ai.openai.api-key=abc123", + "spring.ai.openai.audio.transcription.base-url=TEST_BASE_URL2", + "spring.ai.openai.audio.transcription.api-key=456", + "spring.ai.openai.audio.transcription.options.model=MODEL_XYZ", + "spring.ai.openai.audio.transcription.options.temperature=0.55") + // @formatter:on + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + var transcriptionProperties = context.getBean(OpenAiAudioTranscriptionProperties.class); + var connectionProperties = context.getBean(OpenAiConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(transcriptionProperties.getApiKey()).isEqualTo("456"); + assertThat(transcriptionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL2"); + + assertThat(transcriptionProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(transcriptionProperties.getOptions().getTemperature()).isEqualTo(0.55f); + }); + } + + @Test + public void speechProperties() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.openai.base-url=TEST_BASE_URL", + "spring.ai.openai.api-key=abc123", + "spring.ai.openai.audio.speech.options.model=TTS_1", + "spring.ai.openai.audio.speech.options.voice=alloy", + "spring.ai.openai.audio.speech.options.response-format=mp3", + "spring.ai.openai.audio.speech.options.speed=0.75") + // @formatter:on + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + var speechProperties = context.getBean(OpenAiAudioSpeechProperties.class); + var connectionProperties = context.getBean(OpenAiConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(speechProperties.getApiKey()).isNull(); + assertThat(speechProperties.getBaseUrl()).isNull(); + + assertThat(speechProperties.getOptions().getModel()).isEqualTo("TTS_1"); + assertThat(speechProperties.getOptions().getVoice()) + .isEqualTo(OpenAiAudioApi.SpeechRequest.Voice.ALLOY); + assertThat(speechProperties.getOptions().getResponseFormat()) + .isEqualTo(OpenAiAudioApi.SpeechRequest.AudioResponseFormat.MP3); + assertThat(speechProperties.getOptions().getSpeed()).isEqualTo(0.75f); + }); + } + + @Test + public void speechPropertiesTest() { + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.openai.base-url=TEST_BASE_URL", + "spring.ai.openai.api-key=abc123", + "spring.ai.openai.audio.speech.options.model=TTS_1", + "spring.ai.openai.audio.speech.options.voice=alloy", + "spring.ai.openai.audio.speech.options.response-format=mp3", + "spring.ai.openai.audio.speech.options.speed=0.75") + // @formatter:on + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + var speechProperties = context.getBean(OpenAiAudioSpeechProperties.class); + var connectionProperties = context.getBean(OpenAiConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(speechProperties.getOptions().getModel()).isEqualTo("TTS_1"); + assertThat(speechProperties.getOptions().getVoice()) + .isEqualTo(OpenAiAudioApi.SpeechRequest.Voice.ALLOY); + assertThat(speechProperties.getOptions().getResponseFormat()) + .isEqualTo(OpenAiAudioApi.SpeechRequest.AudioResponseFormat.MP3); + assertThat(speechProperties.getOptions().getSpeed()).isEqualTo(0.75f); + }); + } + + @Test + public void speechOverrideConnectionPropertiesTest() { + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.openai.base-url=TEST_BASE_URL", + "spring.ai.openai.api-key=abc123", + "spring.ai.openai.audio.speech.base-url=TEST_BASE_URL2", + "spring.ai.openai.audio.speech.api-key=456", + "spring.ai.openai.audio.speech.options.model=TTS_2", + "spring.ai.openai.audio.speech.options.voice=echo", + "spring.ai.openai.audio.speech.options.response-format=opus", + "spring.ai.openai.audio.speech.options.speed=0.5") + // @formatter:on + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + var speechProperties = context.getBean(OpenAiAudioSpeechProperties.class); + var connectionProperties = context.getBean(OpenAiConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(speechProperties.getApiKey()).isEqualTo("456"); + assertThat(speechProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL2"); + + assertThat(speechProperties.getOptions().getModel()).isEqualTo("TTS_2"); + assertThat(speechProperties.getOptions().getVoice()).isEqualTo(OpenAiAudioApi.SpeechRequest.Voice.ECHO); + assertThat(speechProperties.getOptions().getResponseFormat()) + .isEqualTo(OpenAiAudioApi.SpeechRequest.AudioResponseFormat.OPUS); + assertThat(speechProperties.getOptions().getSpeed()).isEqualTo(0.5f); + }); + } + + @Test + public void embeddingProperties() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.openai.base-url=TEST_BASE_URL", + "spring.ai.openai.api-key=abc123", + "spring.ai.openai.embedding.options.model=MODEL_XYZ") + // @formatter:on + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + var embeddingProperties = context.getBean(OpenAiEmbeddingProperties.class); + var connectionProperties = context.getBean(OpenAiConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(embeddingProperties.getApiKey()).isNull(); + assertThat(embeddingProperties.getBaseUrl()).isNull(); + + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + }); + } + + @Test + public void embeddingOverrideConnectionProperties() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.openai.base-url=TEST_BASE_URL", + "spring.ai.openai.api-key=abc123", + "spring.ai.openai.embedding.base-url=TEST_BASE_URL2", + "spring.ai.openai.embedding.api-key=456", + "spring.ai.openai.embedding.options.model=MODEL_XYZ") + // @formatter:on + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + var embeddingProperties = context.getBean(OpenAiEmbeddingProperties.class); + var connectionProperties = context.getBean(OpenAiConnectionProperties.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 imageProperties() { + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.openai.base-url=TEST_BASE_URL", + "spring.ai.openai.api-key=abc123", + "spring.ai.openai.image.options.model=MODEL_XYZ", + "spring.ai.openai.image.options.n=3") + // @formatter:on + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + var imageProperties = context.getBean(OpenAiImageProperties.class); + var connectionProperties = context.getBean(OpenAiConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(imageProperties.getApiKey()).isNull(); + assertThat(imageProperties.getBaseUrl()).isNull(); + + assertThat(imageProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(imageProperties.getOptions().getN()).isEqualTo(3); + }); + } + + @Test + public void imageOverrideConnectionProperties() { + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.openai.base-url=TEST_BASE_URL", + "spring.ai.openai.api-key=abc123", + "spring.ai.openai.image.base-url=TEST_BASE_URL2", + "spring.ai.openai.image.api-key=456", + "spring.ai.openai.image.options.model=MODEL_XYZ", + "spring.ai.openai.image.options.n=3") + // @formatter:on + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + var imageProperties = context.getBean(OpenAiImageProperties.class); + var connectionProperties = context.getBean(OpenAiConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(imageProperties.getApiKey()).isEqualTo("456"); + assertThat(imageProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL2"); + + assertThat(imageProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(imageProperties.getOptions().getN()).isEqualTo(3); + }); + } + + @Test + public void chatOptionsTest() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.openai.api-key=API_KEY", + "spring.ai.openai.base-url=TEST_BASE_URL", + + "spring.ai.openai.chat.options.model=MODEL_XYZ", + "spring.ai.openai.chat.options.frequencyPenalty=-1.5", + "spring.ai.openai.chat.options.logitBias.myTokenId=-5", + "spring.ai.openai.chat.options.maxTokens=123", + "spring.ai.openai.chat.options.n=10", + "spring.ai.openai.chat.options.presencePenalty=0", + "spring.ai.openai.chat.options.seed=66", + "spring.ai.openai.chat.options.stop=boza,koza", + "spring.ai.openai.chat.options.temperature=0.55", + "spring.ai.openai.chat.options.topP=0.56", + + // "spring.ai.openai.chat.options.toolChoice.functionName=toolChoiceFunctionName", + "spring.ai.openai.chat.options.toolChoice=" + ModelOptionsUtils.toJsonString(ToolChoiceBuilder.FUNCTION("toolChoiceFunctionName")), + + "spring.ai.openai.chat.options.tools[0].function.name=myFunction1", + "spring.ai.openai.chat.options.tools[0].function.description=function description", + "spring.ai.openai.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"] + } + """, + "spring.ai.openai.chat.options.user=userXYZ" + ) + // @formatter:on + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(OpenAiChatProperties.class); + var connectionProperties = context.getBean(OpenAiConnectionProperties.class); + var embeddingProperties = context.getBean(OpenAiEmbeddingProperties.class); + + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY"); + + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("text-embedding-ada-002"); + + assertThat(chatProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(chatProperties.getOptions().getFrequencyPenalty()).isEqualTo(-1.5); + assertThat(chatProperties.getOptions().getLogitBias().get("myTokenId")).isEqualTo(-5); + assertThat(chatProperties.getOptions().getMaxTokens()).isEqualTo(123); + assertThat(chatProperties.getOptions().getN()).isEqualTo(10); + assertThat(chatProperties.getOptions().getPresencePenalty()).isEqualTo(0); + assertThat(chatProperties.getOptions().getSeed()).isEqualTo(66); + assertThat(chatProperties.getOptions().getStop()).contains("boza", "koza"); + assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55); + assertThat(chatProperties.getOptions().getTopP()).isEqualTo(0.56); + + JSONAssert.assertEquals("{\"type\":\"function\",\"function\":{\"name\":\"toolChoiceFunctionName\"}}", + "" + chatProperties.getOptions().getToolChoice(), JSONCompareMode.LENIENT); + + assertThat(chatProperties.getOptions().getUser()).isEqualTo("userXYZ"); + + assertThat(chatProperties.getOptions().getTools()).hasSize(1); + var tool = chatProperties.getOptions().getTools().get(0); + assertThat(tool.getType()).isEqualTo(OpenAiApi.FunctionTool.Type.FUNCTION); + var function = tool.getFunction(); + assertThat(function.getName()).isEqualTo("myFunction1"); + assertThat(function.getDescription()).isEqualTo("function description"); + assertThat(function.getParameters()).isNotEmpty(); + }); + } + + @Test + public void transcriptionOptionsTest() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.openai.api-key=API_KEY", + "spring.ai.openai.base-url=TEST_BASE_URL", + + "spring.ai.openai.audio.transcription.options.model=MODEL_XYZ", + "spring.ai.openai.audio.transcription.options.language=en", + "spring.ai.openai.audio.transcription.options.prompt=Er, yes, I think so", + "spring.ai.openai.audio.transcription.options.responseFormat=JSON", + "spring.ai.openai.audio.transcription.options.temperature=0.55" + ) + // @formatter:on + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + var transcriptionProperties = context.getBean(OpenAiAudioTranscriptionProperties.class); + var connectionProperties = context.getBean(OpenAiConnectionProperties.class); + var embeddingProperties = context.getBean(OpenAiEmbeddingProperties.class); + + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY"); + + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("text-embedding-ada-002"); + + assertThat(transcriptionProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(transcriptionProperties.getOptions().getLanguage()).isEqualTo("en"); + assertThat(transcriptionProperties.getOptions().getPrompt()).isEqualTo("Er, yes, I think so"); + assertThat(transcriptionProperties.getOptions().getResponseFormat()) + .isEqualTo(OpenAiAudioApi.TranscriptResponseFormat.JSON); + assertThat(transcriptionProperties.getOptions().getTemperature()).isEqualTo(0.55f); + }); + } + + @Test + public void embeddingOptionsTest() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.openai.api-key=API_KEY", + "spring.ai.openai.base-url=TEST_BASE_URL", + + "spring.ai.openai.embedding.options.model=MODEL_XYZ", + "spring.ai.openai.embedding.options.encodingFormat=MyEncodingFormat", + "spring.ai.openai.embedding.options.user=userXYZ" + ) + // @formatter:on + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + var connectionProperties = context.getBean(OpenAiConnectionProperties.class); + var embeddingProperties = context.getBean(OpenAiEmbeddingProperties.class); + + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY"); + + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(embeddingProperties.getOptions().getEncodingFormat()).isEqualTo("MyEncodingFormat"); + assertThat(embeddingProperties.getOptions().getUser()).isEqualTo("userXYZ"); + }); + } + + @Test + public void imageOptionsTest() { + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.openai.api-key=API_KEY", + "spring.ai.openai.base-url=TEST_BASE_URL", + + "spring.ai.openai.image.options.n=3", + "spring.ai.openai.image.options.model=MODEL_XYZ", + "spring.ai.openai.image.options.quality=hd", + "spring.ai.openai.image.options.response_format=url", + "spring.ai.openai.image.options.size=1024x1024", + "spring.ai.openai.image.options.width=1024", + "spring.ai.openai.image.options.height=1024", + "spring.ai.openai.image.options.style=vivid", + "spring.ai.openai.image.options.user=userXYZ" + ) + // @formatter:on + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + var imageProperties = context.getBean(OpenAiImageProperties.class); + var connectionProperties = context.getBean(OpenAiConnectionProperties.class); + + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY"); + + assertThat(imageProperties.getOptions().getN()).isEqualTo(3); + assertThat(imageProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(imageProperties.getOptions().getQuality()).isEqualTo("hd"); + assertThat(imageProperties.getOptions().getResponseFormat()).isEqualTo("url"); + assertThat(imageProperties.getOptions().getSize()).isEqualTo("1024x1024"); + assertThat(imageProperties.getOptions().getWidth()).isEqualTo(1024); + assertThat(imageProperties.getOptions().getHeight()).isEqualTo(1024); + assertThat(imageProperties.getOptions().getStyle()).isEqualTo("vivid"); + assertThat(imageProperties.getOptions().getUser()).isEqualTo("userXYZ"); + }); + } + + @Test + void embeddingActivation() { + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL", + "spring.ai.openai.embedding.enabled=false") + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiEmbeddingModel.class)).isEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL") + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiEmbeddingModel.class)).isNotEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL", + "spring.ai.openai.embedding.enabled=true") + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiEmbeddingModel.class)).isNotEmpty(); + }); + } + + @Test + void chatActivation() { + new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL", + "spring.ai.openai.chat.enabled=false") + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiChatModel.class)).isEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL") + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiChatModel.class)).isNotEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL", + "spring.ai.openai.chat.enabled=true") + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiChatModel.class)).isNotEmpty(); + }); + + } + + @Test + void imageActivation() { + new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL", + "spring.ai.openai.image.enabled=false") + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiImageProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiImageModel.class)).isEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL") + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiImageProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiImageModel.class)).isNotEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL", + "spring.ai.openai.image.enabled=true") + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiImageProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiImageModel.class)).isNotEmpty(); + }); + + } + + @Test + void audioSpeechActivation() { + new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL", + "spring.ai.openai.audio.speech.enabled=false") + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiAudioSpeechProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiAudioSpeechModel.class)).isEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL") + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiAudioSpeechProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiAudioSpeechModel.class)).isNotEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL", + "spring.ai.openai.audio.speech.enabled=true") + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiAudioSpeechProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiAudioSpeechModel.class)).isNotEmpty(); + }); + + } + + @Test + void audioTranscriptionActivation() { + new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL", + "spring.ai.openai.audio.transcription.enabled=false") + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiAudioTranscriptionProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiAudioTranscriptionModel.class)).isEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL") + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiAudioTranscriptionProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiAudioTranscriptionModel.class)).isNotEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL", + "spring.ai.openai.audio.transcription.enabled=true") + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiAudioTranscriptionProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiAudioTranscriptionModel.class)).isNotEmpty(); + }); + + } + +} diff --git a/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/OpenAiResponseFormatPropertiesTests.java b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/OpenAiResponseFormatPropertiesTests.java new file mode 100644 index 00000000000..c7f5baaec12 --- /dev/null +++ b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/OpenAiResponseFormatPropertiesTests.java @@ -0,0 +1,357 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.openai; + +import org.junit.jupiter.api.Test; + +import org.springframework.ai.openai.OpenAiAudioSpeechModel; +import org.springframework.ai.openai.OpenAiAudioTranscriptionModel; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiEmbeddingModel; +import org.springframework.ai.openai.OpenAiImageModel; +import org.springframework.ai.openai.api.OpenAiAudioApi; +import org.springframework.ai.openai.api.ResponseFormat; +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 {@link OpenAiChatProperties} #options#responseFormat support. + * + * @author Christian Tzolov + */ +public class OpenAiResponseFormatPropertiesTests { + + @Test + public void responseFormatJsonSchema() { + + String responseFormatJsonSchema = """ + { + "$schema" : "https://json-schema.org/draft/2020-12/schema", + "type" : "object", + "properties" : { + "someString" : { + "type" : "string" + } + }, + "additionalProperties" : false + } + """; + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.openai.api-key=API_KEY", + + "spring.ai.openai.chat.options.response-format.type=JSON_SCHEMA", + "spring.ai.openai.chat.options.response-format.name=MyName", + "spring.ai.openai.chat.options.response-format.schema=" + responseFormatJsonSchema, + "spring.ai.openai.chat.options.response-format.strict=true" + ) + // @formatter:on + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(OpenAiChatProperties.class); + var connectionProperties = context.getBean(OpenAiConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY"); + + assertThat(chatProperties.getOptions().getResponseFormat()) + .isEqualTo(new ResponseFormat(ResponseFormat.Type.JSON_SCHEMA, responseFormatJsonSchema)); + }); + } + + @Test + public void responseFormatJsonObject() { + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.api-key=API_KEY", + "spring.ai.openai.chat.options.response-format.type=JSON_OBJECT") + + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(OpenAiChatProperties.class); + + assertThat(chatProperties.getOptions().getResponseFormat()) + .isEqualTo(ResponseFormat.builder().type(ResponseFormat.Type.JSON_OBJECT).build()); + }); + } + + @Test + public void emptyResponseFormat() { + + new ApplicationContextRunner().withPropertyValues("spring.ai.openai.api-key=API_KEY") + + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(OpenAiChatProperties.class); + + assertThat(chatProperties.getOptions().getResponseFormat()).isNull(); + }); + } + + @Test + public void transcriptionOptionsTest() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.openai.api-key=API_KEY", + "spring.ai.openai.base-url=TEST_BASE_URL", + + "spring.ai.openai.audio.transcription.options.model=MODEL_XYZ", + "spring.ai.openai.audio.transcription.options.language=en", + "spring.ai.openai.audio.transcription.options.prompt=Er, yes, I think so", + "spring.ai.openai.audio.transcription.options.responseFormat=JSON", + "spring.ai.openai.audio.transcription.options.temperature=0.55" + ) + // @formatter:on + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + var transcriptionProperties = context.getBean(OpenAiAudioTranscriptionProperties.class); + var connectionProperties = context.getBean(OpenAiConnectionProperties.class); + var embeddingProperties = context.getBean(OpenAiEmbeddingProperties.class); + + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY"); + + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("text-embedding-ada-002"); + + assertThat(transcriptionProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(transcriptionProperties.getOptions().getLanguage()).isEqualTo("en"); + assertThat(transcriptionProperties.getOptions().getPrompt()).isEqualTo("Er, yes, I think so"); + assertThat(transcriptionProperties.getOptions().getResponseFormat()) + .isEqualTo(OpenAiAudioApi.TranscriptResponseFormat.JSON); + assertThat(transcriptionProperties.getOptions().getTemperature()).isEqualTo(0.55f); + }); + } + + @Test + public void embeddingOptionsTest() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.openai.api-key=API_KEY", + "spring.ai.openai.base-url=TEST_BASE_URL", + + "spring.ai.openai.embedding.options.model=MODEL_XYZ", + "spring.ai.openai.embedding.options.encodingFormat=MyEncodingFormat", + "spring.ai.openai.embedding.options.user=userXYZ" + ) + // @formatter:on + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + var connectionProperties = context.getBean(OpenAiConnectionProperties.class); + var embeddingProperties = context.getBean(OpenAiEmbeddingProperties.class); + + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY"); + + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(embeddingProperties.getOptions().getEncodingFormat()).isEqualTo("MyEncodingFormat"); + assertThat(embeddingProperties.getOptions().getUser()).isEqualTo("userXYZ"); + }); + } + + @Test + public void imageOptionsTest() { + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.openai.api-key=API_KEY", + "spring.ai.openai.base-url=TEST_BASE_URL", + + "spring.ai.openai.image.options.n=3", + "spring.ai.openai.image.options.model=MODEL_XYZ", + "spring.ai.openai.image.options.quality=hd", + "spring.ai.openai.image.options.response_format=url", + "spring.ai.openai.image.options.size=1024x1024", + "spring.ai.openai.image.options.width=1024", + "spring.ai.openai.image.options.height=1024", + "spring.ai.openai.image.options.style=vivid", + "spring.ai.openai.image.options.user=userXYZ" + ) + // @formatter:on + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + var imageProperties = context.getBean(OpenAiImageProperties.class); + var connectionProperties = context.getBean(OpenAiConnectionProperties.class); + + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY"); + + assertThat(imageProperties.getOptions().getN()).isEqualTo(3); + assertThat(imageProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(imageProperties.getOptions().getQuality()).isEqualTo("hd"); + assertThat(imageProperties.getOptions().getResponseFormat()).isEqualTo("url"); + assertThat(imageProperties.getOptions().getSize()).isEqualTo("1024x1024"); + assertThat(imageProperties.getOptions().getWidth()).isEqualTo(1024); + assertThat(imageProperties.getOptions().getHeight()).isEqualTo(1024); + assertThat(imageProperties.getOptions().getStyle()).isEqualTo("vivid"); + assertThat(imageProperties.getOptions().getUser()).isEqualTo("userXYZ"); + }); + } + + @Test + void embeddingActivation() { + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL", + "spring.ai.openai.embedding.enabled=false") + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiEmbeddingModel.class)).isEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL") + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiEmbeddingModel.class)).isNotEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL", + "spring.ai.openai.embedding.enabled=true") + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiEmbeddingModel.class)).isNotEmpty(); + }); + } + + @Test + void chatActivation() { + new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL", + "spring.ai.openai.chat.enabled=false") + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiChatModel.class)).isEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL") + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiChatModel.class)).isNotEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL", + "spring.ai.openai.chat.enabled=true") + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiChatModel.class)).isNotEmpty(); + }); + + } + + @Test + void imageActivation() { + new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL", + "spring.ai.openai.image.enabled=false") + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiImageProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiImageModel.class)).isEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL") + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiImageProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiImageModel.class)).isNotEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL", + "spring.ai.openai.image.enabled=true") + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiImageProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiImageModel.class)).isNotEmpty(); + }); + + } + + @Test + void audioSpeechActivation() { + new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL", + "spring.ai.openai.audio.speech.enabled=false") + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiAudioSpeechProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiAudioSpeechModel.class)).isEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL") + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiAudioSpeechProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiAudioSpeechModel.class)).isNotEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL", + "spring.ai.openai.audio.speech.enabled=true") + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiAudioSpeechProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiAudioSpeechModel.class)).isNotEmpty(); + }); + + } + + @Test + void audioTranscriptionActivation() { + new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL", + "spring.ai.openai.audio.transcription.enabled=false") + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiAudioTranscriptionProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiAudioTranscriptionModel.class)).isEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL") + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiAudioTranscriptionProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiAudioTranscriptionModel.class)).isNotEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL", + "spring.ai.openai.audio.transcription.enabled=true") + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiAudioTranscriptionProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiAudioTranscriptionModel.class)).isNotEmpty(); + }); + + } + +} diff --git a/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/FunctionCallbackInPrompt2IT.java b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/FunctionCallbackInPrompt2IT.java new file mode 100644 index 00000000000..21c11f7af7b --- /dev/null +++ b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/FunctionCallbackInPrompt2IT.java @@ -0,0 +1,156 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.openai.tool; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.autoconfigure.openai.OpenAiAutoConfiguration; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.api.OpenAiApi.ChatModel; +import org.springframework.ai.tool.function.FunctionToolCallback; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".*") +public class FunctionCallbackInPrompt2IT { + + private final Logger logger = LoggerFactory.getLogger(FunctionCallbackInPromptIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.apiKey=" + System.getenv("OPENAI_API_KEY")) + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)); + + @Test + void functionCallTest() { + this.contextRunner.withPropertyValues("spring.ai.openai.chat.options.model=" + ChatModel.GPT_4_O_MINI.getName()) + .run(context -> { + + OpenAiChatModel chatModel = context.getBean(OpenAiChatModel.class); + + ChatClient chatClient = ChatClient.builder(chatModel).build(); + + // @formatter:off + chatClient.prompt() + .user("Tell me a joke?") + .call().content(); + + String content = ChatClient.builder(chatModel).build().prompt() + .user("What's the weather like in San Francisco, Tokyo, and Paris?") + .functions(FunctionToolCallback + .builder("CurrentWeatherService", new MockWeatherService()) + .description("Get the weather in location") + .inputType(MockWeatherService.Request.class) + .build()) + .call().content(); + // @formatter:on + + logger.info("Response: {}", content); + + assertThat(content).contains("30", "10", "15"); + }); + } + + @Test + void lambdaFunctionCallTest() { + Map state = new ConcurrentHashMap<>(); + + record LightInfo(String roomName, boolean isOn) { + } + + this.contextRunner.run(context -> { + + OpenAiChatModel chatModel = context.getBean(OpenAiChatModel.class); + + // @formatter:off + String content = ChatClient.builder(chatModel).build().prompt() + .user("Turn the light on in the kitchen and in the living room!") + .functions(FunctionToolCallback + .builder("turnLight", (LightInfo lightInfo) -> { + logger.info("Turning light to [" + lightInfo.isOn + "] in " + lightInfo.roomName()); + state.put(lightInfo.roomName(), lightInfo.isOn()); + }) + .description("Turn light on or off in a room") + .inputType(LightInfo.class) + .build()) + .call().content(); + // @formatter:on + logger.info("Response: {}", content); + assertThat(state).containsEntry("kitchen", Boolean.TRUE); + assertThat(state).containsEntry("living room", Boolean.TRUE); + }); + } + + @Test + void functionCallTest2() { + this.contextRunner.withPropertyValues("spring.ai.openai.chat.options.model=" + ChatModel.GPT_4_O_MINI.getName()) + .run(context -> { + + OpenAiChatModel chatModel = context.getBean(OpenAiChatModel.class); + + // @formatter:off + String content = ChatClient.builder(chatModel).build().prompt() + .user("What's the weather like in Amsterdam?") + .functions(FunctionToolCallback + .builder("CurrentWeatherService", input -> "18 degrees Celsius") + .description("Get the weather in location") + .inputType(MockWeatherService.Request.class) + .build()) + .call().content(); + // @formatter:on + logger.info("Response: {}", content); + + assertThat(content).contains("18"); + }); + } + + @Test + void streamingFunctionCallTest() { + + this.contextRunner.withPropertyValues("spring.ai.openai.chat.options.model=" + ChatModel.GPT_4_O_MINI.getName()) + .run(context -> { + + OpenAiChatModel chatModel = context.getBean(OpenAiChatModel.class); + + // @formatter:off + String content = ChatClient.builder(chatModel).build().prompt() + .user("What's the weather like in San Francisco, Tokyo, and Paris?") + .functions(FunctionToolCallback + .builder("CurrentWeatherService", new MockWeatherService()) + .description("Get the weather in location") + .inputType(MockWeatherService.Request.class) + .build()) + .stream().content() + .collectList().block().stream().collect(Collectors.joining()); + // @formatter:on + + logger.info("Response: {}", content); + + assertThat(content).contains("30", "10", "15"); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/FunctionCallbackInPromptIT.java b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/FunctionCallbackInPromptIT.java new file mode 100644 index 00000000000..14e60ab6801 --- /dev/null +++ b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/FunctionCallbackInPromptIT.java @@ -0,0 +1,117 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.openai.tool; + +import java.util.List; +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.autoconfigure.openai.OpenAiAutoConfiguration; +import org.springframework.ai.chat.messages.AssistantMessage; +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.Prompt; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.ai.openai.api.OpenAiApi.ChatModel; +import org.springframework.ai.tool.function.FunctionToolCallback; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".*") +public class FunctionCallbackInPromptIT { + + private final Logger logger = LoggerFactory.getLogger(FunctionCallbackInPromptIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.apiKey=" + System.getenv("OPENAI_API_KEY")) + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)); + + @Test + void functionCallTest() { + this.contextRunner + .withPropertyValues("spring.ai.openai.chat.options.model=" + ChatModel.GPT_4_O_MINI.getName(), + "spring.ai.openai.chat.options.temperature=0.1") + .run(context -> { + + OpenAiChatModel chatModel = context.getBean(OpenAiChatModel.class); + + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris?"); + + var promptOptions = OpenAiChatOptions.builder() + .functionCallbacks( + List.of(FunctionToolCallback.builder("CurrentWeatherService", new MockWeatherService()) + .description("Get the weather in location") + .inputType(MockWeatherService.Request.class) + .build())) + .build(); + + ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), promptOptions)); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + }); + } + + @Test + void streamingFunctionCallTest() { + + this.contextRunner + .withPropertyValues("spring.ai.openai.chat.options.model=" + ChatModel.GPT_4_O_MINI.getName(), + "spring.ai.openai.chat.options.temperature=0.5") + .run(context -> { + + OpenAiChatModel chatModel = context.getBean(OpenAiChatModel.class); + + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris?"); + + var promptOptions = OpenAiChatOptions.builder() + .functionCallbacks( + List.of(FunctionToolCallback.builder("CurrentWeatherService", new MockWeatherService()) + .description("Get the weather in location") + .inputType(MockWeatherService.Request.class) + .build())) + .build(); + + Flux response = chatModel.stream(new Prompt(List.of(userMessage), 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).contains("30", "10", "15"); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/FunctionCallbackWithPlainFunctionBeanIT.java b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/FunctionCallbackWithPlainFunctionBeanIT.java new file mode 100644 index 00000000000..bed893c1089 --- /dev/null +++ b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/FunctionCallbackWithPlainFunctionBeanIT.java @@ -0,0 +1,431 @@ +/* + * 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.autoconfigure.openai.tool; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.BeforeEach; +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.autoconfigure.openai.OpenAiAutoConfiguration; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.messages.AssistantMessage; +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.model.ToolContext; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.model.tool.ToolCallingChatOptions; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.ai.openai.api.OpenAiApi.ChatModel; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Description; + +import static org.assertj.core.api.Assertions.assertThat; + +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".*") +class FunctionCallbackWithPlainFunctionBeanIT { + + private static final Logger logger = LoggerFactory.getLogger(FunctionCallbackWithPlainFunctionBeanIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.apiKey=" + System.getenv("OPENAI_API_KEY"), + "spring.ai.openai.chat.options.model=" + ChatModel.GPT_4_O_MINI.getName()) + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .withUserConfiguration(Config.class); + + private static Map feedback = new ConcurrentHashMap<>(); + + @BeforeEach + void setUp() { + feedback.clear(); + } + + @Test + void functionCallingVoidInput() { + this.contextRunner.run(context -> { + + OpenAiChatModel chatModel = context.getBean(OpenAiChatModel.class); + + // Test weatherFunction + UserMessage userMessage = new UserMessage("Turn the light on in the living room"); + + ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), + OpenAiChatOptions.builder().function("turnLivingRoomLightOn").build())); + + logger.info("Response: {}", response); + assertThat(feedback).hasSize(1); + assertThat(feedback.get("turnLivingRoomLightOn")).isEqualTo(Boolean.valueOf(true)); + }); + } + + @Test + void functionCallingSupplier() { + this.contextRunner.run(context -> { + + OpenAiChatModel chatModel = context.getBean(OpenAiChatModel.class); + + // Test weatherFunction + UserMessage userMessage = new UserMessage("Turn the light on in the living room"); + + ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), + OpenAiChatOptions.builder().function("turnLivingRoomLightOnSupplier").build())); + + logger.info("Response: {}", response); + assertThat(feedback).hasSize(1); + assertThat(feedback.get("turnLivingRoomLightOnSupplier")).isEqualTo(Boolean.valueOf(true)); + }); + } + + @Test + void functionCallingVoidOutput() { + this.contextRunner.run(context -> { + + OpenAiChatModel chatModel = context.getBean(OpenAiChatModel.class); + + // Test weatherFunction + UserMessage userMessage = new UserMessage("Turn the light on in the kitchen and in the living room"); + + ChatResponse response = chatModel + .call(new Prompt(List.of(userMessage), OpenAiChatOptions.builder().function("turnLight").build())); + + logger.info("Response: {}", response); + assertThat(feedback).hasSize(2); + assertThat(feedback.get("kitchen")).isEqualTo(Boolean.valueOf(true)); + assertThat(feedback.get("living room")).isEqualTo(Boolean.valueOf(true)); + }); + } + + @Test + void functionCallingConsumer() { + this.contextRunner.run(context -> { + + OpenAiChatModel chatModel = context.getBean(OpenAiChatModel.class); + + // Test weatherFunction + UserMessage userMessage = new UserMessage("Turn the light on in the kitchen and in the living room"); + + ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), + OpenAiChatOptions.builder().function("turnLightConsumer").build())); + + logger.info("Response: {}", response); + assertThat(feedback).hasSize(2); + assertThat(feedback.get("kitchen")).isEqualTo(Boolean.valueOf(true)); + assertThat(feedback.get("living room")).isEqualTo(Boolean.valueOf(true)); + + }); + } + + @Test + void trainScheduler() { + this.contextRunner.run(context -> { + + OpenAiChatModel chatModel = context.getBean(OpenAiChatModel.class); + + // Test weatherFunction + UserMessage userMessage = new UserMessage( + "Please schedule a train from San Francisco to Los Angeles on 2023-12-25"); + + ToolCallingChatOptions functionOptions = ToolCallingChatOptions.builder() + .toolNames("trainReservation") + .build(); + + ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), functionOptions)); + + logger.info("Response: {}", response.getResult().getOutput().getText()); + }); + } + + @Test + void functionCallWithDirectBiFunction() { + this.contextRunner.run(context -> { + + OpenAiChatModel chatModel = context.getBean(OpenAiChatModel.class); + + ChatClient chatClient = ChatClient.builder(chatModel).build(); + + String content = chatClient.prompt("What's the weather like in San Francisco, Tokyo, and Paris?") + .functions("weatherFunctionWithContext") + .toolContext(Map.of("sessionId", "123")) + .call() + .content(); + logger.info(content); + + // Test weatherFunction + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? You can call the following functions 'weatherFunction'"); + + ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), + OpenAiChatOptions.builder() + .function("weatherFunctionWithContext") + .toolContext(Map.of("sessionId", "123")) + .build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + + }); + } + + @Test + void functionCallWithBiFunctionClass() { + this.contextRunner.run(context -> { + + OpenAiChatModel chatModel = context.getBean(OpenAiChatModel.class); + + ChatClient chatClient = ChatClient.builder(chatModel).build(); + + String content = chatClient.prompt("What's the weather like in San Francisco, Tokyo, and Paris?") + .functions("weatherFunctionWithClassBiFunction") + .toolContext(Map.of("sessionId", "123")) + .call() + .content(); + logger.info(content); + + // Test weatherFunction + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? You can call the following functions 'weatherFunction'"); + + ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), + OpenAiChatOptions.builder() + .function("weatherFunctionWithClassBiFunction") + .toolContext(Map.of("sessionId", "123")) + .build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + + }); + } + + @Test + void functionCallTest() { + this.contextRunner.run(context -> { + + OpenAiChatModel chatModel = context.getBean(OpenAiChatModel.class); + + // Test weatherFunction + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? You can call the following functions 'weatherFunction'"); + + ChatResponse response = chatModel.call( + new Prompt(List.of(userMessage), OpenAiChatOptions.builder().function("weatherFunction").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + + // Test weatherFunctionTwo + response = chatModel.call(new Prompt(List.of(userMessage), + OpenAiChatOptions.builder().function("weatherFunctionTwo").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + + }); + } + + @Test + void functionCallWithPortableFunctionCallingOptions() { + this.contextRunner.run(context -> { + + OpenAiChatModel chatModel = context.getBean(OpenAiChatModel.class); + + // Test weatherFunction + UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); + + ToolCallingChatOptions functionOptions = ToolCallingChatOptions.builder() + .toolNames("weatherFunction") + .build(); + + ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), functionOptions)); + + logger.info("Response: {}", response.getResult().getOutput().getText()); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + }); + } + + @Test + void streamFunctionCallTest() { + this.contextRunner.run(context -> { + + OpenAiChatModel chatModel = context.getBean(OpenAiChatModel.class); + + // Test weatherFunction + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? You can call the following functions 'weatherFunction'"); + + Flux response = chatModel.stream( + new Prompt(List.of(userMessage), OpenAiChatOptions.builder().function("weatherFunction").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).contains("30", "10", "15"); + + // Test weatherFunctionTwo + response = chatModel.stream(new Prompt(List.of(userMessage), + OpenAiChatOptions.builder().function("weatherFunctionTwo").build())); + + 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).isNotEmpty().withFailMessage("Content returned from OpenAI model is empty"); + assertThat(content).contains("30", "10", "15"); + + }); + } + + @Configuration + static class Config { + + @Bean + @Description("Get the weather in location") + public MyBiFunction weatherFunctionWithClassBiFunction() { + return new MyBiFunction(); + } + + @Bean + @Description("Get the weather in location") + public BiFunction weatherFunctionWithContext() { + return (request, context) -> new MockWeatherService().apply(request); + } + + @Bean + @Description("Get the weather in location") + public Function weatherFunction() { + return new MockWeatherService(); + } + + // Relies on the Request's JsonClassDescription annotation to provide the + // function description. + @Bean + public Function weatherFunctionTwo() { + MockWeatherService weatherService = new MockWeatherService(); + return (weatherService::apply); + } + + @Bean + @Description("Turn light on or off in a room") + public Function turnLight() { + return (LightInfo lightInfo) -> { + logger.info("Turning light to [" + lightInfo.isOn + "] in " + lightInfo.roomName()); + feedback.put(lightInfo.roomName(), lightInfo.isOn()); + return null; + }; + } + + @Bean + @Description("Turn light on or off in a room") + public Consumer turnLightConsumer() { + return (LightInfo lightInfo) -> { + logger.info("Turning light to [" + lightInfo.isOn + "] in " + lightInfo.roomName()); + feedback.put(lightInfo.roomName(), lightInfo.isOn()); + }; + } + + @Bean + @Description("Turns light on in the living room") + public Function turnLivingRoomLightOn() { + return (Void v) -> { + logger.info("Turning light on in the living room"); + feedback.put("turnLivingRoomLightOn", Boolean.TRUE); + return "Done"; + }; + } + + @Bean + @Description("Turns light on in the living room") + public Supplier turnLivingRoomLightOnSupplier() { + return () -> { + logger.info("Turning light on in the living room"); + feedback.put("turnLivingRoomLightOnSupplier", Boolean.TRUE); + return "Done"; + }; + } + + @Bean + @Description("Schedule a train reservation") + public Function, TrainSearchResponse> trainReservation() { + return (TrainSearchRequest request) -> { + logger.info("Turning light to [" + request.data().from() + "] in " + request.data().to()); + return new TrainSearchResponse<>( + new TrainSearchScheduleResponse(request.data().from(), request.data().to(), "", "123")); + }; + } + + } + + public static class MyBiFunction + implements BiFunction { + + @Override + public MockWeatherService.Response apply(MockWeatherService.Request request, ToolContext context) { + return new MockWeatherService().apply(request); + } + + } + + record LightInfo(String roomName, boolean isOn) { + } + + record TrainSearchSchedule(String from, String to, String date) { + } + + record TrainSearchScheduleResponse(String from, String to, String date, String trainNumber) { + } + + record TrainSearchRequest(T data) { + } + + record TrainSearchResponse(T data) { + } + +} diff --git a/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/MockWeatherService.java b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/MockWeatherService.java new file mode 100644 index 00000000000..489942fcc20 --- /dev/null +++ b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/MockWeatherService.java @@ -0,0 +1,97 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.openai.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; + +/** + * Mock 3rd party weather service. + * + * @author Christian Tzolov + */ +public class MockWeatherService implements Function { + + @Override + public Response apply(Request request) { + + double temperature = 10; + 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 = "lat") @JsonPropertyDescription("The city latitude") double lat, + @JsonProperty(required = true, value = "lon") @JsonPropertyDescription("The city longitude") double lon, + @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/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/OpenAiFunctionCallback2IT.java b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/OpenAiFunctionCallback2IT.java new file mode 100644 index 00000000000..aab1aabd8c3 --- /dev/null +++ b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/OpenAiFunctionCallback2IT.java @@ -0,0 +1,107 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.openai.tool; + +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.autoconfigure.openai.OpenAiAutoConfiguration; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.api.OpenAiApi.ChatModel; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.function.FunctionToolCallback; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".*") +public class OpenAiFunctionCallback2IT { + + private final Logger logger = LoggerFactory.getLogger(OpenAiFunctionCallback2IT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.apiKey=" + System.getenv("OPENAI_API_KEY"), + "spring.ai.openai.chat.options.model=" + ChatModel.GPT_4_O_MINI.getName()) + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .withUserConfiguration(Config.class); + + @Test + void functionCallTest() { + this.contextRunner.withPropertyValues("spring.ai.openai.chat.options.temperature=0.1").run(context -> { + + OpenAiChatModel chatModel = context.getBean(OpenAiChatModel.class); + + // @formatter:off + ChatClient chatClient = ChatClient.builder(chatModel) + .defaultFunctions("WeatherInfo") + .defaultUser(u -> u.text("What's the weather like in {cities}?")) + .build(); + + String content = chatClient.prompt() + .user(u -> u.param("cities", "San Francisco, Tokyo, Paris")) + .call().content(); + // @formatter:on + + logger.info("Response: {}", content); + + assertThat(content).contains("30", "10", "15"); + }); + } + + @Test + void streamFunctionCallTest() { + this.contextRunner.withPropertyValues("spring.ai.openai.chat.options.temperature=0.1").run(context -> { + + OpenAiChatModel chatModel = context.getBean(OpenAiChatModel.class); + + // @formatter:off + String content = ChatClient.builder(chatModel).build().prompt() + .functions("WeatherInfo") + .user("What's the weather like in San Francisco, Tokyo, and Paris?") + .stream().content() + .collectList().block().stream().collect(Collectors.joining()); + // @formatter:on + + logger.info("Response: {}", content); + + assertThat(content).contains("30", "10", "15"); + }); + } + + @Configuration + static class Config { + + @Bean + public ToolCallback weatherFunctionInfo() { + + return FunctionToolCallback.builder("WeatherInfo", new MockWeatherService()) + .description("Get the weather in location") + .inputType(MockWeatherService.Request.class) + .build(); + } + + } + +} diff --git a/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/OpenAiFunctionCallbackIT.java b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/OpenAiFunctionCallbackIT.java new file mode 100644 index 00000000000..d4914146cec --- /dev/null +++ b/auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/OpenAiFunctionCallbackIT.java @@ -0,0 +1,118 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.openai.tool; + +import java.util.List; +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.autoconfigure.openai.OpenAiAutoConfiguration; +import org.springframework.ai.chat.messages.AssistantMessage; +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.Prompt; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.ai.openai.api.OpenAiApi.ChatModel; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.function.FunctionToolCallback; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".*") +public class OpenAiFunctionCallbackIT { + + private final Logger logger = LoggerFactory.getLogger(OpenAiFunctionCallbackIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.apiKey=" + System.getenv("OPENAI_API_KEY"), + "spring.ai.openai.chat.options.model=" + ChatModel.GPT_4_O_MINI.getName()) + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .withUserConfiguration(Config.class); + + @Test + void functionCallTest() { + this.contextRunner.withPropertyValues("spring.ai.openai.chat.options.temperature=0.1").run(context -> { + + OpenAiChatModel chatModel = context.getBean(OpenAiChatModel.class); + + UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); + + ChatResponse response = chatModel + .call(new Prompt(List.of(userMessage), OpenAiChatOptions.builder().function("WeatherInfo").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + + }); + } + + @Test + void streamFunctionCallTest() { + this.contextRunner.withPropertyValues("spring.ai.openai.chat.options.temperature=0.1").run(context -> { + + OpenAiChatModel chatModel = context.getBean(OpenAiChatModel.class); + + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? You can call the following functions 'WeatherInfo'"); + + Flux response = chatModel + .stream(new Prompt(List.of(userMessage), OpenAiChatOptions.builder().function("WeatherInfo").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("30.0", "30"); + assertThat(content).containsAnyOf("10.0", "10"); + assertThat(content).containsAnyOf("15.0", "15"); + + }); + } + + @Configuration + static class Config { + + @Bean + public ToolCallback weatherFunctionInfo() { + + return FunctionToolCallback.builder("WeatherInfo", new MockWeatherService()) + .description("Get the weather in location") + .inputType(MockWeatherService.Request.class) + .build(); + } + + } + +} diff --git a/auto-configurations/models/spring-ai-postgresml-spring-boot-autoconfigure/pom.xml b/auto-configurations/models/spring-ai-postgresml-spring-boot-autoconfigure/pom.xml new file mode 100644 index 00000000000..b04b72f5d70 --- /dev/null +++ b/auto-configurations/models/spring-ai-postgresml-spring-boot-autoconfigure/pom.xml @@ -0,0 +1,111 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../../pom.xml + + spring-ai-postgresml-spring-boot-autoconfigure + jar + Spring AI PostgresML Auto Configuration + Spring AI PostgresML 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-postgresml + ${project.parent.version} + true + + + + + + org.springframework.ai + spring-ai-chat-client-spring-boot-autoconfigure + ${project.parent.version} + true + + + + org.springframework.ai + spring-ai-chat-model-spring-boot-autoconfigure + ${project.parent.version} + true + + + + org.springframework.ai + spring-ai-retry-spring-boot-autoconfigure + ${project.parent.version} + true + + + + + org.springframework.boot + spring-boot-starter + true + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + + org.springframework.ai + spring-ai-test + ${project.parent.version} + test + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.boot + spring-boot-testcontainers + test + + + + org.testcontainers + junit-jupiter + test + + + + org.testcontainers + postgresql + test + + + + org.mockito + mockito-core + test + + + + diff --git a/auto-configurations/models/spring-ai-postgresml-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/postgresml/PostgresMlAutoConfiguration.java b/auto-configurations/models/spring-ai-postgresml-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/postgresml/PostgresMlAutoConfiguration.java new file mode 100644 index 00000000000..e9bbac87e6e --- /dev/null +++ b/auto-configurations/models/spring-ai-postgresml-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/postgresml/PostgresMlAutoConfiguration.java @@ -0,0 +1,51 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.postgresml; + +import org.springframework.ai.postgresml.PostgresMlEmbeddingModel; +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.jdbc.JdbcTemplateAutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * Auto-configuration class for PostgresMlEmbeddingModel. + * + * @author Utkarsh Srivastava + * @author Christian Tzolov + */ +@AutoConfiguration(after = JdbcTemplateAutoConfiguration.class) +@ConditionalOnClass(PostgresMlEmbeddingModel.class) +@EnableConfigurationProperties(PostgresMlEmbeddingProperties.class) +public class PostgresMlAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = PostgresMlEmbeddingProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + public PostgresMlEmbeddingModel postgresMlEmbeddingModel(JdbcTemplate jdbcTemplate, + PostgresMlEmbeddingProperties embeddingProperties) { + + return new PostgresMlEmbeddingModel(jdbcTemplate, embeddingProperties.getOptions(), + embeddingProperties.isCreateExtension()); + } + +} diff --git a/auto-configurations/models/spring-ai-postgresml-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/postgresml/PostgresMlEmbeddingProperties.java b/auto-configurations/models/spring-ai-postgresml-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/postgresml/PostgresMlEmbeddingProperties.java new file mode 100644 index 00000000000..91c53ae1e5a --- /dev/null +++ b/auto-configurations/models/spring-ai-postgresml-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/postgresml/PostgresMlEmbeddingProperties.java @@ -0,0 +1,87 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.postgresml; + +import java.util.Map; + +import org.springframework.ai.document.MetadataMode; +import org.springframework.ai.postgresml.PostgresMlEmbeddingModel; +import org.springframework.ai.postgresml.PostgresMlEmbeddingOptions; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; +import org.springframework.util.Assert; + +/** + * Configuration properties for Postgres ML. + * + * @author Utkarsh Srivastava + * @author Christian Tzolov + */ +@ConfigurationProperties(PostgresMlEmbeddingProperties.CONFIG_PREFIX) +public class PostgresMlEmbeddingProperties { + + public static final String CONFIG_PREFIX = "spring.ai.postgresml.embedding"; + + /** + * Enable Postgres ML embedding model. + */ + private boolean enabled = true; + + /** + * Create the extensions required for embedding + */ + private boolean createExtension; + + @NestedConfigurationProperty + private PostgresMlEmbeddingOptions options = PostgresMlEmbeddingOptions.builder() + .transformer(PostgresMlEmbeddingModel.DEFAULT_TRANSFORMER_MODEL) + .vectorType(PostgresMlEmbeddingModel.VectorType.PG_ARRAY) + .kwargs(Map.of()) + .metadataMode(MetadataMode.EMBED) + .build(); + + public PostgresMlEmbeddingOptions getOptions() { + return this.options; + } + + public void setOptions(PostgresMlEmbeddingOptions options) { + Assert.notNull(options, "options must not be null."); + Assert.notNull(options.getTransformer(), "transformer must not be null."); + Assert.notNull(options.getVectorType(), "vectorType must not be null."); + Assert.notNull(options.getKwargs(), "kwargs must not be null."); + Assert.notNull(options.getMetadataMode(), "metadataMode must not be null."); + + this.options = options; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public boolean isCreateExtension() { + return this.createExtension; + } + + public void setCreateExtension(boolean createExtension) { + this.createExtension = createExtension; + } + +} diff --git a/auto-configurations/models/spring-ai-postgresml-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/models/spring-ai-postgresml-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..1664474937a --- /dev/null +++ b/auto-configurations/models/spring-ai-postgresml-spring-boot-autoconfigure/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.autoconfigure.postgresml.PostgresMlAutoConfiguration diff --git a/auto-configurations/models/spring-ai-postgresml-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/postgresml/PostgresMlAutoConfigurationIT.java b/auto-configurations/models/spring-ai-postgresml-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/postgresml/PostgresMlAutoConfigurationIT.java new file mode 100644 index 00000000000..b41b2fe5e67 --- /dev/null +++ b/auto-configurations/models/spring-ai-postgresml-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/postgresml/PostgresMlAutoConfigurationIT.java @@ -0,0 +1,113 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.postgresml; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.List; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.ai.postgresml.PostgresMlEmbeddingModel; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.jdbc.core.JdbcTemplate; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Utkarsh Srivastava + */ +@JdbcTest(properties = "logging.level.sql=TRACE") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Testcontainers +@Disabled("Disabled from automatic execution, as it requires an excessive amount of memory (over 9GB)!") +public class PostgresMlAutoConfigurationIT { + + @Container + @ServiceConnection + static PostgreSQLContainer postgres = new PostgreSQLContainer<>( + DockerImageName.parse("ghcr.io/postgresml/postgresml:2.8.1").asCompatibleSubstituteFor("postgres")) + .withCommand("sleep", "infinity") + .withUsername("postgresml") + .withPassword("postgresml") + .withDatabaseName("postgresml") + .waitingFor(Wait.forLogMessage(".*Starting dashboard.*\\s", 1)); + + @Autowired + JdbcTemplate jdbcTemplate; + + @Test + void embedding() { + ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withBean(JdbcTemplate.class, () -> this.jdbcTemplate) + .withConfiguration(AutoConfigurations.of(PostgresMlAutoConfiguration.class)); + contextRunner.run(context -> { + PostgresMlEmbeddingModel embeddingModel = context.getBean(PostgresMlEmbeddingModel.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()).isZero(); + assertThat(embeddingResponse.getResults().get(1).getOutput()).isNotEmpty(); + assertThat(embeddingResponse.getResults().get(1).getIndex()).isEqualTo(1); + + assertThat(embeddingModel.dimensions()).isEqualTo(768); + }); + } + + @Test + void embeddingActivation() { + new ApplicationContextRunner().withBean(JdbcTemplate.class, () -> this.jdbcTemplate) + .withConfiguration(AutoConfigurations.of(PostgresMlAutoConfiguration.class)) + .withPropertyValues("spring.ai.postgresml.embedding.enabled=false") + .run(context -> { + assertThat(context.getBeansOfType(PostgresMlEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(PostgresMlEmbeddingModel.class)).isEmpty(); + }); + + new ApplicationContextRunner().withBean(JdbcTemplate.class, () -> this.jdbcTemplate) + .withConfiguration(AutoConfigurations.of(PostgresMlAutoConfiguration.class)) + .withPropertyValues("spring.ai.postgresml.embedding.enabled=true") + .run(context -> { + assertThat(context.getBeansOfType(PostgresMlEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(PostgresMlEmbeddingModel.class)).isNotEmpty(); + }); + + new ApplicationContextRunner().withBean(JdbcTemplate.class, () -> this.jdbcTemplate) + .withConfiguration(AutoConfigurations.of(PostgresMlAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(PostgresMlEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(PostgresMlEmbeddingModel.class)).isNotEmpty(); + }); + + } + +} diff --git a/auto-configurations/models/spring-ai-postgresml-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/postgresml/PostgresMlEmbeddingPropertiesTests.java b/auto-configurations/models/spring-ai-postgresml-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/postgresml/PostgresMlEmbeddingPropertiesTests.java new file mode 100644 index 00000000000..be6a5358595 --- /dev/null +++ b/auto-configurations/models/spring-ai-postgresml-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/postgresml/PostgresMlEmbeddingPropertiesTests.java @@ -0,0 +1,64 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.postgresml; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import org.springframework.ai.document.MetadataMode; +import org.springframework.ai.postgresml.PostgresMlEmbeddingModel; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit Tests for {@link PostgresMlEmbeddingProperties}. + * + * @author Utkarsh Srivastava + * @author Christian Tzolov + */ +@SpringBootTest(properties = { "spring.ai.postgresml.embedding.options.metadata-mode=all", + "spring.ai.postgresml.embedding.options.kwargs.key1=value1", + "spring.ai.postgresml.embedding.options.kwargs.key2=value2", + "spring.ai.postgresml.embedding.options.transformer=abc123" }) +class PostgresMlEmbeddingPropertiesTests { + + @Autowired + private PostgresMlEmbeddingProperties postgresMlProperties; + + @Test + void postgresMlPropertiesAreCorrect() { + assertThat(this.postgresMlProperties).isNotNull(); + assertThat(this.postgresMlProperties.getOptions().getTransformer()).isEqualTo("abc123"); + assertThat(this.postgresMlProperties.getOptions().getVectorType()) + .isEqualTo(PostgresMlEmbeddingModel.VectorType.PG_ARRAY); + assertThat(this.postgresMlProperties.getOptions().getKwargs()) + .isEqualTo(Map.of("key1", "value1", "key2", "value2")); + assertThat(this.postgresMlProperties.getOptions().getMetadataMode()).isEqualTo(MetadataMode.ALL); + } + + @SpringBootConfiguration + @EnableConfigurationProperties(PostgresMlEmbeddingProperties.class) + static class TestConfiguration { + + } + +} diff --git a/auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/pom.xml b/auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/pom.xml new file mode 100644 index 00000000000..372c46f6105 --- /dev/null +++ b/auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/pom.xml @@ -0,0 +1,86 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../../pom.xml + + spring-ai-qianfan-spring-boot-autoconfigure + jar + Spring AI QianFan AI Auto Configuration + Spring AI QianFan AI 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-qianfan + ${project.parent.version} + true + + + + + + org.springframework.ai + spring-ai-chat-client-spring-boot-autoconfigure + ${project.parent.version} + true + + + + org.springframework.ai + spring-ai-retry-spring-boot-autoconfigure + ${project.parent.version} + true + + + + + org.springframework.boot + spring-boot-starter + true + + + + org.springframework.boot + spring-boot-configuration-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-qianfan-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/qianfan/QianFanAutoConfiguration.java b/auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/qianfan/QianFanAutoConfiguration.java new file mode 100644 index 00000000000..7af2dc19913 --- /dev/null +++ b/auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/qianfan/QianFanAutoConfiguration.java @@ -0,0 +1,165 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.qianfan; + +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.chat.observation.ChatModelObservationConvention; +import org.springframework.ai.embedding.observation.EmbeddingModelObservationConvention; +import org.springframework.ai.image.observation.ImageModelObservationConvention; +import org.springframework.ai.model.function.DefaultFunctionCallbackResolver; +import org.springframework.ai.model.function.FunctionCallbackResolver; +import org.springframework.ai.qianfan.QianFanChatModel; +import org.springframework.ai.qianfan.QianFanEmbeddingModel; +import org.springframework.ai.qianfan.QianFanImageModel; +import org.springframework.ai.qianfan.api.QianFanApi; +import org.springframework.ai.qianfan.api.QianFanImageApi; +import org.springframework.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.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.web.client.ResponseErrorHandler; +import org.springframework.web.client.RestClient; + +/** + * {@link AutoConfiguration Auto-configuration} for QianFan Chat, Embedding, and Image + * Models. + * + * @author Geng Rong + */ +@AutoConfiguration(after = { RestClientAutoConfiguration.class, SpringAiRetryAutoConfiguration.class }) +@ConditionalOnClass(QianFanApi.class) +@EnableConfigurationProperties({ QianFanConnectionProperties.class, QianFanChatProperties.class, + QianFanEmbeddingProperties.class, QianFanImageProperties.class }) +public class QianFanAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = QianFanChatProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + public QianFanChatModel qianFanChatModel(QianFanConnectionProperties commonProperties, + QianFanChatProperties chatProperties, ObjectProvider restClientBuilderProvider, + RetryTemplate retryTemplate, ResponseErrorHandler responseErrorHandler, + ObjectProvider observationRegistry, + ObjectProvider observationConvention) { + + var qianFanApi = qianFanApi(chatProperties.getBaseUrl(), commonProperties.getBaseUrl(), + chatProperties.getApiKey(), commonProperties.getApiKey(), chatProperties.getSecretKey(), + commonProperties.getSecretKey(), restClientBuilderProvider.getIfAvailable(RestClient::builder), + responseErrorHandler); + + var chatModel = new QianFanChatModel(qianFanApi, chatProperties.getOptions(), retryTemplate, + observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)); + + observationConvention.ifAvailable(chatModel::setObservationConvention); + + return chatModel; + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = QianFanEmbeddingProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + public QianFanEmbeddingModel qianFanEmbeddingModel(QianFanConnectionProperties commonProperties, + QianFanEmbeddingProperties embeddingProperties, + ObjectProvider restClientBuilderProvider, RetryTemplate retryTemplate, + ResponseErrorHandler responseErrorHandler, ObjectProvider observationRegistry, + ObjectProvider observationConvention) { + + var qianFanApi = qianFanApi(embeddingProperties.getBaseUrl(), commonProperties.getBaseUrl(), + embeddingProperties.getApiKey(), commonProperties.getApiKey(), embeddingProperties.getSecretKey(), + commonProperties.getSecretKey(), restClientBuilderProvider.getIfAvailable(RestClient::builder), + responseErrorHandler); + + var embeddingModel = new QianFanEmbeddingModel(qianFanApi, embeddingProperties.getMetadataMode(), + embeddingProperties.getOptions(), retryTemplate, + observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)); + + observationConvention.ifAvailable(embeddingModel::setObservationConvention); + + return embeddingModel; + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = QianFanImageProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + public QianFanImageModel qianFanImageModel(QianFanConnectionProperties commonProperties, + QianFanImageProperties imageProperties, ObjectProvider restClientBuilderProvider, + RetryTemplate retryTemplate, ResponseErrorHandler responseErrorHandler, + ObjectProvider observationRegistry, + ObjectProvider observationConvention) { + + String apiKey = StringUtils.hasText(imageProperties.getApiKey()) ? imageProperties.getApiKey() + : commonProperties.getApiKey(); + + String secretKey = StringUtils.hasText(imageProperties.getSecretKey()) ? imageProperties.getSecretKey() + : commonProperties.getSecretKey(); + + String baseUrl = StringUtils.hasText(imageProperties.getBaseUrl()) ? imageProperties.getBaseUrl() + : commonProperties.getBaseUrl(); + + Assert.hasText(apiKey, "QianFan API key must be set. Use the property: spring.ai.qianfan.api-key"); + Assert.hasText(secretKey, "QianFan secret key must be set. Use the property: spring.ai.qianfan.secret-key"); + Assert.hasText(baseUrl, "QianFan base URL must be set. Use the property: spring.ai.qianfan.base-url"); + + var qianFanImageApi = new QianFanImageApi(baseUrl, apiKey, secretKey, + restClientBuilderProvider.getIfAvailable(RestClient::builder), responseErrorHandler); + + var imageModel = new QianFanImageModel(qianFanImageApi, imageProperties.getOptions(), retryTemplate, + observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)); + + observationConvention.ifAvailable(imageModel::setObservationConvention); + + return imageModel; + } + + private QianFanApi qianFanApi(String baseUrl, String commonBaseUrl, String apiKey, String commonApiKey, + String secretKey, String commonSecretKey, RestClient.Builder restClientBuilder, + ResponseErrorHandler responseErrorHandler) { + + String resolvedBaseUrl = StringUtils.hasText(baseUrl) ? baseUrl : commonBaseUrl; + Assert.hasText(resolvedBaseUrl, "QianFan base URL must be set"); + + String resolvedApiKey = StringUtils.hasText(apiKey) ? apiKey : commonApiKey; + Assert.hasText(resolvedApiKey, "QianFan API key must be set"); + + String resolvedSecretKey = StringUtils.hasText(secretKey) ? secretKey : commonSecretKey; + Assert.hasText(resolvedSecretKey, "QianFan Secret key must be set"); + + return new QianFanApi(resolvedBaseUrl, resolvedApiKey, resolvedSecretKey, restClientBuilder, + responseErrorHandler); + } + + @Bean + @ConditionalOnMissingBean + public FunctionCallbackResolver springAiFunctionManager(ApplicationContext context) { + DefaultFunctionCallbackResolver manager = new DefaultFunctionCallbackResolver(); + manager.setApplicationContext(context); + return manager; + } + +} diff --git a/auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/qianfan/QianFanChatProperties.java b/auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/qianfan/QianFanChatProperties.java new file mode 100644 index 00000000000..2d2487e7be7 --- /dev/null +++ b/auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/qianfan/QianFanChatProperties.java @@ -0,0 +1,65 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.qianfan; + +import org.springframework.ai.qianfan.QianFanChatOptions; +import org.springframework.ai.qianfan.api.QianFanApi; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * Configuration properties for QianFan chat model. + * + * @author Geng Rong + */ +@ConfigurationProperties(QianFanChatProperties.CONFIG_PREFIX) +public class QianFanChatProperties extends QianFanParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.qianfan.chat"; + + public static final String DEFAULT_CHAT_MODEL = QianFanApi.ChatModel.ERNIE_Speed_8K.value; + + private static final Double DEFAULT_TEMPERATURE = 0.7; + + /** + * Enable QianFan chat client. + */ + private boolean enabled = true; + + @NestedConfigurationProperty + private QianFanChatOptions options = QianFanChatOptions.builder() + .model(DEFAULT_CHAT_MODEL) + .temperature(DEFAULT_TEMPERATURE) + .build(); + + public QianFanChatOptions getOptions() { + return this.options; + } + + public void setOptions(QianFanChatOptions options) { + this.options = options; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + +} diff --git a/auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/qianfan/QianFanConnectionProperties.java b/auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/qianfan/QianFanConnectionProperties.java new file mode 100644 index 00000000000..90cb8c7a22f --- /dev/null +++ b/auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/qianfan/QianFanConnectionProperties.java @@ -0,0 +1,33 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.qianfan; + +import org.springframework.ai.qianfan.api.QianFanConstants; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(QianFanConnectionProperties.CONFIG_PREFIX) +public class QianFanConnectionProperties extends QianFanParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.qianfan"; + + public static final String DEFAULT_BASE_URL = QianFanConstants.DEFAULT_BASE_URL; + + public QianFanConnectionProperties() { + super.setBaseUrl(DEFAULT_BASE_URL); + } + +} diff --git a/auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/qianfan/QianFanEmbeddingProperties.java b/auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/qianfan/QianFanEmbeddingProperties.java new file mode 100644 index 00000000000..b1514424e2c --- /dev/null +++ b/auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/qianfan/QianFanEmbeddingProperties.java @@ -0,0 +1,71 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.qianfan; + +import org.springframework.ai.document.MetadataMode; +import org.springframework.ai.qianfan.QianFanEmbeddingOptions; +import org.springframework.ai.qianfan.api.QianFanApi; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * Configuration properties for QianFan embedding model. + * + * @author Geng Rong + */ +@ConfigurationProperties(QianFanEmbeddingProperties.CONFIG_PREFIX) +public class QianFanEmbeddingProperties extends QianFanParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.qianfan.embedding"; + + /** + * Enable QianFan embedding client. + */ + private boolean enabled = true; + + private MetadataMode metadataMode = MetadataMode.EMBED; + + @NestedConfigurationProperty + private QianFanEmbeddingOptions options = QianFanEmbeddingOptions.builder() + .model(QianFanApi.DEFAULT_EMBEDDING_MODEL) + .build(); + + public QianFanEmbeddingOptions getOptions() { + return this.options; + } + + public void setOptions(QianFanEmbeddingOptions options) { + this.options = options; + } + + public MetadataMode getMetadataMode() { + return this.metadataMode; + } + + public void setMetadataMode(MetadataMode metadataMode) { + this.metadataMode = metadataMode; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + +} diff --git a/auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/qianfan/QianFanImageProperties.java b/auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/qianfan/QianFanImageProperties.java new file mode 100644 index 00000000000..50d00f04c1b --- /dev/null +++ b/auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/qianfan/QianFanImageProperties.java @@ -0,0 +1,63 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.qianfan; + +import org.springframework.ai.qianfan.QianFanImageOptions; +import org.springframework.ai.qianfan.api.QianFanImageApi; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * QianFan Image autoconfiguration properties. + * + * @author Geng Rong + */ +@ConfigurationProperties(QianFanImageProperties.CONFIG_PREFIX) +public class QianFanImageProperties extends QianFanParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.qianfan.image"; + + public static final String DEFAULT_IMAGE_MODEL = QianFanImageApi.ImageModel.Stable_Diffusion_XL.getValue(); + + /** + * Enable QianFan image model. + */ + private boolean enabled = true; + + /** + * Options for QianFan Image API. + */ + @NestedConfigurationProperty + private QianFanImageOptions options = QianFanImageOptions.builder().model(DEFAULT_IMAGE_MODEL).build(); + + public QianFanImageOptions getOptions() { + return this.options; + } + + public void setOptions(QianFanImageOptions options) { + this.options = options; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + +} diff --git a/auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/qianfan/QianFanParentProperties.java b/auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/qianfan/QianFanParentProperties.java new file mode 100644 index 00000000000..543bec0c6fe --- /dev/null +++ b/auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/qianfan/QianFanParentProperties.java @@ -0,0 +1,54 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.qianfan; + +/** + * @author Geng Rong + */ +class QianFanParentProperties { + + private String apiKey; + + private String secretKey; + + private String baseUrl; + + public String getApiKey() { + return this.apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getSecretKey() { + return this.secretKey; + } + + public void setSecretKey(String secretKey) { + this.secretKey = secretKey; + } + + public String getBaseUrl() { + return this.baseUrl; + } + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + +} diff --git a/auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..444ccbed095 --- /dev/null +++ b/auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/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.autoconfigure.qianfan.QianFanAutoConfiguration diff --git a/auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/qianfan/QianFanAutoConfigurationIT.java b/auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/qianfan/QianFanAutoConfigurationIT.java new file mode 100644 index 00000000000..78842991257 --- /dev/null +++ b/auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/qianfan/QianFanAutoConfigurationIT.java @@ -0,0 +1,114 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.qianfan; + +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariables; +import reactor.core.publisher.Flux; + +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.ai.image.ImagePrompt; +import org.springframework.ai.image.ImageResponse; +import org.springframework.ai.qianfan.QianFanChatModel; +import org.springframework.ai.qianfan.QianFanEmbeddingModel; +import org.springframework.ai.qianfan.QianFanImageModel; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Geng Rong + */ +@EnabledIfEnvironmentVariables({ @EnabledIfEnvironmentVariable(named = "QIANFAN_API_KEY", matches = ".+"), + @EnabledIfEnvironmentVariable(named = "QIANFAN_SECRET_KEY", matches = ".+") }) +public class QianFanAutoConfigurationIT { + + private static final Log logger = LogFactory.getLog(QianFanAutoConfigurationIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.qianfan.apiKey=" + System.getenv("QIANFAN_API_KEY"), + "spring.ai.qianfan.secretKey=" + System.getenv("QIANFAN_SECRET_KEY")) + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, QianFanAutoConfiguration.class)); + + @Test + void generate() { + this.contextRunner.run(context -> { + QianFanChatModel client = context.getBean(QianFanChatModel.class); + String response = client.call("Hello"); + assertThat(response).isNotEmpty(); + logger.info("Response: " + response); + }); + } + + @Test + void generateStreaming() { + this.contextRunner.run(context -> { + QianFanChatModel client = context.getBean(QianFanChatModel.class); + Flux responseFlux = client.stream(new Prompt(new UserMessage("Hello"))); + String response = Objects.requireNonNull(responseFlux.collectList().block()) + .stream() + .map(chatResponse -> chatResponse.getResults().get(0).getOutput().getText()) + .collect(Collectors.joining()); + assertThat(response).isNotEmpty(); + logger.info("Response: " + response); + }); + } + + @Test + void embedding() { + this.contextRunner.run(context -> { + QianFanEmbeddingModel embeddingClient = context.getBean(QianFanEmbeddingModel.class); + + EmbeddingResponse embeddingResponse = embeddingClient + .embedForResponse(List.of("Hello World", "World is big and salvation is near")); + assertThat(embeddingResponse.getResults()).hasSize(2); + assertThat(embeddingResponse.getResults().get(0).getOutput()).isNotEmpty(); + assertThat(embeddingResponse.getResults().get(0).getIndex()).isEqualTo(0); + assertThat(embeddingResponse.getResults().get(1).getOutput()).isNotEmpty(); + assertThat(embeddingResponse.getResults().get(1).getIndex()).isEqualTo(1); + + assertThat(embeddingClient.dimensions()).isEqualTo(1024); + }); + } + + @Test + void generateImage() { + this.contextRunner.withPropertyValues("spring.ai.qianfan.image.options.size=1024x1024").run(context -> { + QianFanImageModel imageModel = context.getBean(QianFanImageModel.class); + ImageResponse imageResponse = imageModel.call(new ImagePrompt("forest")); + assertThat(imageResponse.getResults()).hasSize(1); + assertThat(imageResponse.getResult().getOutput().getUrl()).isNull(); + assertThat(imageResponse.getResult().getOutput().getB64Json()).isNotEmpty(); + logger.info("Generated image: " + imageResponse.getResult().getOutput().getB64Json()); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/qianfan/QianFanPropertiesTests.java b/auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/qianfan/QianFanPropertiesTests.java new file mode 100644 index 00000000000..1a0ee813f35 --- /dev/null +++ b/auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/qianfan/QianFanPropertiesTests.java @@ -0,0 +1,440 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.qianfan; + +import org.junit.jupiter.api.Test; + +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.qianfan.QianFanChatModel; +import org.springframework.ai.qianfan.QianFanEmbeddingModel; +import org.springframework.ai.qianfan.QianFanImageModel; +import org.springframework.ai.qianfan.api.QianFanApi; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit Tests for + * {@link org.springframework.ai.autoconfigure.qianfan.QianFanConnectionProperties}, + * {@link org.springframework.ai.autoconfigure.qianfan.QianFanChatProperties} and + * {@link org.springframework.ai.autoconfigure.qianfan.QianFanEmbeddingProperties}. + * + * @author Geng Rong + */ +public class QianFanPropertiesTests { + + @Test + public void chatProperties() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.qianfan.base-url=TEST_BASE_URL", + "spring.ai.qianfan.api-key=abc123", + "spring.ai.qianfan.secret-key=def123", + "spring.ai.qianfan.chat.options.model=MODEL_XYZ", + "spring.ai.qianfan.chat.options.temperature=0.55") + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, QianFanAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(QianFanChatProperties.class); + var connectionProperties = context.getBean(QianFanConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getSecretKey()).isEqualTo("def123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(chatProperties.getApiKey()).isNull(); + assertThat(chatProperties.getBaseUrl()).isNull(); + + assertThat(chatProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55); + }); + } + + @Test + public void chatOverrideConnectionProperties() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.qianfan.base-url=TEST_BASE_URL", + "spring.ai.qianfan.api-key=abc123", + "spring.ai.qianfan.secret-key=def123", + "spring.ai.qianfan.chat.base-url=TEST_BASE_URL2", + "spring.ai.qianfan.chat.api-key=456", + "spring.ai.qianfan.chat.secret-key=def456", + "spring.ai.qianfan.chat.options.model=MODEL_XYZ", + "spring.ai.qianfan.chat.options.temperature=0.55") + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, QianFanAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(QianFanChatProperties.class); + var connectionProperties = context.getBean(QianFanConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getSecretKey()).isEqualTo("def123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(chatProperties.getApiKey()).isEqualTo("456"); + assertThat(chatProperties.getSecretKey()).isEqualTo("def456"); + assertThat(chatProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL2"); + + assertThat(chatProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55); + }); + } + + @Test + public void embeddingProperties() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.qianfan.base-url=TEST_BASE_URL", + "spring.ai.qianfan.api-key=abc123", + "spring.ai.qianfan.secret-key=def123", + "spring.ai.qianfan.embedding.options.model=MODEL_XYZ") + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, QianFanAutoConfiguration.class)) + .run(context -> { + var embeddingProperties = context.getBean(QianFanEmbeddingProperties.class); + var connectionProperties = context.getBean(QianFanConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getSecretKey()).isEqualTo("def123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(embeddingProperties.getApiKey()).isNull(); + assertThat(embeddingProperties.getBaseUrl()).isNull(); + + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + }); + } + + @Test + public void embeddingOverrideConnectionProperties() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.qianfan.base-url=TEST_BASE_URL", + "spring.ai.qianfan.api-key=abc123", + "spring.ai.qianfan.secret-key=def123", + "spring.ai.qianfan.embedding.base-url=TEST_BASE_URL2", + "spring.ai.qianfan.embedding.api-key=456", + "spring.ai.qianfan.embedding.secret-key=def456", + "spring.ai.qianfan.embedding.options.model=MODEL_XYZ") + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, QianFanAutoConfiguration.class)) + .run(context -> { + var embeddingProperties = context.getBean(QianFanEmbeddingProperties.class); + var connectionProperties = context.getBean(QianFanConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getSecretKey()).isEqualTo("def123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(embeddingProperties.getApiKey()).isEqualTo("456"); + assertThat(embeddingProperties.getSecretKey()).isEqualTo("def456"); + assertThat(embeddingProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL2"); + + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + }); + } + + @Test + public void chatOptionsTest() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.qianfan.api-key=API_KEY", + "spring.ai.qianfan.secret-key=SECRET_KEY", + "spring.ai.qianfan.base-url=TEST_BASE_URL", + + "spring.ai.qianfan.chat.options.model=MODEL_XYZ", + "spring.ai.qianfan.chat.options.frequencyPenalty=-1.5", + "spring.ai.qianfan.chat.options.logitBias.myTokenId=-5", + "spring.ai.qianfan.chat.options.maxTokens=123", + "spring.ai.qianfan.chat.options.presencePenalty=0", + "spring.ai.qianfan.chat.options.responseFormat.type=json", + "spring.ai.qianfan.chat.options.stop=boza,koza", + "spring.ai.qianfan.chat.options.temperature=0.55", + "spring.ai.qianfan.chat.options.topP=0.56" + ) + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, QianFanAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(QianFanChatProperties.class); + var connectionProperties = context.getBean(QianFanConnectionProperties.class); + var embeddingProperties = context.getBean(QianFanEmbeddingProperties.class); + + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY"); + assertThat(connectionProperties.getSecretKey()).isEqualTo("SECRET_KEY"); + + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("bge_large_zh"); + + assertThat(chatProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(chatProperties.getOptions().getFrequencyPenalty()).isEqualTo(-1.5); + assertThat(chatProperties.getOptions().getMaxTokens()).isEqualTo(123); + assertThat(chatProperties.getOptions().getPresencePenalty()).isEqualTo(0); + assertThat(chatProperties.getOptions().getResponseFormat()) + .isEqualTo(new QianFanApi.ChatCompletionRequest.ResponseFormat("json")); + assertThat(chatProperties.getOptions().getStop()).contains("boza", "koza"); + assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55); + assertThat(chatProperties.getOptions().getTopP()).isEqualTo(0.56); + }); + } + + @Test + public void embeddingOptionsTest() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.qianfan.api-key=API_KEY", + "spring.ai.qianfan.secret-key=SECRET_KEY", + "spring.ai.qianfan.base-url=TEST_BASE_URL", + + "spring.ai.qianfan.embedding.options.model=MODEL_XYZ", + "spring.ai.qianfan.embedding.options.encodingFormat=MyEncodingFormat" + ) + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, QianFanAutoConfiguration.class)) + .run(context -> { + var connectionProperties = context.getBean(QianFanConnectionProperties.class); + var embeddingProperties = context.getBean(QianFanEmbeddingProperties.class); + + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY"); + assertThat(connectionProperties.getSecretKey()).isEqualTo("SECRET_KEY"); + + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + }); + } + + @Test + void embeddingActivation() { + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.qianfan.api-key=API_KEY", "spring.ai.qianfan.secret-key=SECRET_KEY", + "spring.ai.qianfan.base-url=TEST_BASE_URL", "spring.ai.qianfan.embedding.enabled=false") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, QianFanAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(QianFanEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(QianFanEmbeddingModel.class)).isEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.qianfan.api-key=API_KEY", "spring.ai.qianfan.secret-key=SECRET_KEY", + "spring.ai.qianfan.base-url=TEST_BASE_URL") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, QianFanAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(QianFanEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(QianFanEmbeddingModel.class)).isNotEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.qianfan.api-key=API_KEY", "spring.ai.qianfan.secret-key=SECRET_KEY", + "spring.ai.qianfan.base-url=TEST_BASE_URL", "spring.ai.qianfan.embedding.enabled=true") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, QianFanAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(QianFanEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(QianFanEmbeddingModel.class)).isNotEmpty(); + }); + } + + @Test + void chatActivation() { + new ApplicationContextRunner() + .withPropertyValues("spring.ai.qianfan.api-key=API_KEY", "spring.ai.qianfan.secret-key=SECRET_KEY", + "spring.ai.qianfan.base-url=TEST_BASE_URL", "spring.ai.qianfan.chat.enabled=false") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, QianFanAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(QianFanChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(QianFanChatModel.class)).isEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.qianfan.api-key=API_KEY", "spring.ai.qianfan.secret-key=SECRET_KEY", + "spring.ai.qianfan.base-url=TEST_BASE_URL") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, QianFanAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(QianFanChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(QianFanChatModel.class)).isNotEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.qianfan.api-key=API_KEY", "spring.ai.qianfan.secret-key=SECRET_KEY", + "spring.ai.qianfan.base-url=TEST_BASE_URL", "spring.ai.qianfan.chat.enabled=true") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, QianFanAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(QianFanChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(QianFanChatModel.class)).isNotEmpty(); + }); + + } + + @Test + public void imageProperties() { + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.qianfan.base-url=TEST_BASE_URL", + "spring.ai.qianfan.api-key=abc123", + "spring.ai.qianfan.secret-key=def123", + "spring.ai.qianfan.image.options.model=MODEL_XYZ", + "spring.ai.qianfan.image.options.n=3") + // @formatter:on + .withConfiguration( + AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, RestClientAutoConfiguration.class, + WebClientAutoConfiguration.class, QianFanAutoConfiguration.class)) + .run(context -> { + var imageProperties = context.getBean(QianFanImageProperties.class); + var connectionProperties = context.getBean(QianFanConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getSecretKey()).isEqualTo("def123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(imageProperties.getApiKey()).isNull(); + assertThat(imageProperties.getBaseUrl()).isNull(); + + assertThat(imageProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(imageProperties.getOptions().getN()).isEqualTo(3); + }); + } + + @Test + public void imageOverrideConnectionProperties() { + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.qianfan.base-url=TEST_BASE_URL", + "spring.ai.qianfan.api-key=abc123", + "spring.ai.qianfan.secret-key=def123", + "spring.ai.qianfan.image.base-url=TEST_BASE_URL2", + "spring.ai.qianfan.image.api-key=456", + "spring.ai.qianfan.image.secret-key=def456", + "spring.ai.qianfan.image.options.model=MODEL_XYZ", + "spring.ai.qianfan.image.options.n=3") + // @formatter:on + .withConfiguration( + AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, RestClientAutoConfiguration.class, + WebClientAutoConfiguration.class, QianFanAutoConfiguration.class)) + .run(context -> { + var imageProperties = context.getBean(QianFanImageProperties.class); + var connectionProperties = context.getBean(QianFanConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getSecretKey()).isEqualTo("def123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(imageProperties.getApiKey()).isEqualTo("456"); + assertThat(imageProperties.getSecretKey()).isEqualTo("def456"); + assertThat(imageProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL2"); + + assertThat(imageProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(imageProperties.getOptions().getN()).isEqualTo(3); + }); + } + + @Test + public void imageOptionsTest() { + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.qianfan.api-key=API_KEY", + "spring.ai.qianfan.secret-key=SECRET_KEY", + "spring.ai.qianfan.base-url=TEST_BASE_URL", + + "spring.ai.qianfan.image.options.n=3", + "spring.ai.qianfan.image.options.model=MODEL_XYZ", + "spring.ai.qianfan.image.options.size=1024x1024", + "spring.ai.qianfan.image.options.width=1024", + "spring.ai.qianfan.image.options.height=1024", + "spring.ai.qianfan.image.options.style=vivid", + "spring.ai.qianfan.image.options.user=userXYZ" + ) + // @formatter:on + .withConfiguration( + AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, RestClientAutoConfiguration.class, + WebClientAutoConfiguration.class, QianFanAutoConfiguration.class)) + .run(context -> { + var imageProperties = context.getBean(QianFanImageProperties.class); + var connectionProperties = context.getBean(QianFanConnectionProperties.class); + + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY"); + assertThat(connectionProperties.getSecretKey()).isEqualTo("SECRET_KEY"); + + assertThat(imageProperties.getOptions().getN()).isEqualTo(3); + assertThat(imageProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(imageProperties.getOptions().getSize()).isEqualTo("1024x1024"); + assertThat(imageProperties.getOptions().getWidth()).isEqualTo(1024); + assertThat(imageProperties.getOptions().getHeight()).isEqualTo(1024); + assertThat(imageProperties.getOptions().getStyle()).isEqualTo("vivid"); + assertThat(imageProperties.getOptions().getUser()).isEqualTo("userXYZ"); + }); + } + + @Test + void imageActivation() { + new ApplicationContextRunner() + .withPropertyValues("spring.ai.qianfan.api-key=API_KEY", "spring.ai.qianfan.secret-key=SECRET_KEY", + "spring.ai.qianfan.base-url=TEST_BASE_URL", "spring.ai.qianfan.image.enabled=false") + .withConfiguration( + AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, RestClientAutoConfiguration.class, + WebClientAutoConfiguration.class, QianFanAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(QianFanImageProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(QianFanImageModel.class)).isEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.qianfan.api-key=API_KEY", "spring.ai.qianfan.secret-key=SECRET_KEY", + "spring.ai.qianfan.base-url=TEST_BASE_URL") + .withConfiguration( + AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, RestClientAutoConfiguration.class, + WebClientAutoConfiguration.class, QianFanAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(QianFanImageProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(QianFanImageModel.class)).isNotEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.qianfan.api-key=API_KEY", "spring.ai.qianfan.secret-key=SECRET_KEY", + "spring.ai.qianfan.base-url=TEST_BASE_URL", "spring.ai.qianfan.image.enabled=true") + .withConfiguration( + AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, RestClientAutoConfiguration.class, + WebClientAutoConfiguration.class, QianFanAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(QianFanImageProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(QianFanImageModel.class)).isNotEmpty(); + }); + + } + +} diff --git a/auto-configurations/models/spring-ai-stability-ai-spring-boot-autoconfigure/pom.xml b/auto-configurations/models/spring-ai-stability-ai-spring-boot-autoconfigure/pom.xml new file mode 100644 index 00000000000..321b0be1296 --- /dev/null +++ b/auto-configurations/models/spring-ai-stability-ai-spring-boot-autoconfigure/pom.xml @@ -0,0 +1,86 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../../pom.xml + + spring-ai-stability-ai-spring-boot-autoconfigure + jar + Spring AI Stability AI Auto Configuration + Spring AI Stability AI 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-stability-ai + ${project.parent.version} + true + + + + + + org.springframework.ai + spring-ai-chat-client-spring-boot-autoconfigure + ${project.parent.version} + true + + + + org.springframework.ai + spring-ai-retry-spring-boot-autoconfigure + ${project.parent.version} + true + + + + + org.springframework.boot + spring-boot-starter + true + + + + org.springframework.boot + spring-boot-configuration-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-stability-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/stabilityai/StabilityAiConnectionProperties.java b/auto-configurations/models/spring-ai-stability-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/stabilityai/StabilityAiConnectionProperties.java new file mode 100644 index 00000000000..e39d36e7f2d --- /dev/null +++ b/auto-configurations/models/spring-ai-stability-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/stabilityai/StabilityAiConnectionProperties.java @@ -0,0 +1,33 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.stabilityai; + +import org.springframework.ai.stabilityai.api.StabilityAiApi; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(StabilityAiConnectionProperties.CONFIG_PREFIX) +public class StabilityAiConnectionProperties extends StabilityAiParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.stabilityai"; + + public static final String DEFAULT_BASE_URL = StabilityAiApi.DEFAULT_BASE_URL; + + public StabilityAiConnectionProperties() { + super.setBaseUrl(DEFAULT_BASE_URL); + } + +} diff --git a/auto-configurations/models/spring-ai-stability-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/stabilityai/StabilityAiImageAutoConfiguration.java b/auto-configurations/models/spring-ai-stability-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/stabilityai/StabilityAiImageAutoConfiguration.java new file mode 100644 index 00000000000..266507b86fd --- /dev/null +++ b/auto-configurations/models/spring-ai-stability-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/stabilityai/StabilityAiImageAutoConfiguration.java @@ -0,0 +1,72 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.stabilityai; + +import org.springframework.ai.stabilityai.StabilityAiImageModel; +import org.springframework.ai.stabilityai.api.StabilityAiApi; +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.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClient; + +/** + * {@link AutoConfiguration Auto-configuration} for StabilityAI Image Model. + * + * @author Mark Pollack + * @author Christian Tzolov + * @since 0.8.0 + */ +@AutoConfiguration(after = { RestClientAutoConfiguration.class }) +@ConditionalOnClass(StabilityAiApi.class) +@EnableConfigurationProperties({ StabilityAiConnectionProperties.class, StabilityAiImageProperties.class }) +public class StabilityAiImageAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public StabilityAiApi stabilityAiApi(StabilityAiConnectionProperties commonProperties, + StabilityAiImageProperties imageProperties, ObjectProvider restClientBuilderProvider) { + + String apiKey = StringUtils.hasText(imageProperties.getApiKey()) ? imageProperties.getApiKey() + : commonProperties.getApiKey(); + + String baseUrl = StringUtils.hasText(imageProperties.getBaseUrl()) ? imageProperties.getBaseUrl() + : commonProperties.getBaseUrl(); + + Assert.hasText(apiKey, "StabilityAI API key must be set"); + Assert.hasText(baseUrl, "StabilityAI base URL must be set"); + + return new StabilityAiApi(apiKey, imageProperties.getOptions().getModel(), baseUrl, + restClientBuilderProvider.getIfAvailable(RestClient::builder)); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = StabilityAiImageProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + public StabilityAiImageModel stabilityAiImageModel(StabilityAiApi stabilityAiApi, + StabilityAiImageProperties stabilityAiImageProperties) { + return new StabilityAiImageModel(stabilityAiApi, stabilityAiImageProperties.getOptions()); + } + +} diff --git a/auto-configurations/models/spring-ai-stability-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/stabilityai/StabilityAiImageProperties.java b/auto-configurations/models/spring-ai-stability-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/stabilityai/StabilityAiImageProperties.java new file mode 100644 index 00000000000..2c7ad567270 --- /dev/null +++ b/auto-configurations/models/spring-ai-stability-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/stabilityai/StabilityAiImageProperties.java @@ -0,0 +1,63 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.stabilityai; + +import org.springframework.ai.stabilityai.api.StabilityAiImageOptions; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * Configuration properties for Stability AI image model. + * + * @author Mark Pollack + * @author Christian Tzolov + * @since 0.8.0 + */ +@ConfigurationProperties(StabilityAiImageProperties.CONFIG_PREFIX) +public class StabilityAiImageProperties extends StabilityAiParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.stabilityai.image"; + + /** + * Enable Stability image model. + */ + private boolean enabled = true; + + @NestedConfigurationProperty + private StabilityAiImageOptions options = StabilityAiImageOptions.builder().build(); // stable-diffusion-v1-6 + + // is + // default + // model + + public StabilityAiImageOptions getOptions() { + return this.options; + } + + public void setOptions(StabilityAiImageOptions options) { + this.options = options; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + +} diff --git a/auto-configurations/models/spring-ai-stability-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/stabilityai/StabilityAiParentProperties.java b/auto-configurations/models/spring-ai-stability-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/stabilityai/StabilityAiParentProperties.java new file mode 100644 index 00000000000..b62d9e5e312 --- /dev/null +++ b/auto-configurations/models/spring-ai-stability-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/stabilityai/StabilityAiParentProperties.java @@ -0,0 +1,47 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.stabilityai; + +/** + * Internal parent properties for the StabilityAI properties. + * + * @author Mark Pollack + * @since 0.8.0 + */ +class StabilityAiParentProperties { + + 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-stability-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/models/spring-ai-stability-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..dd1b2b257c0 --- /dev/null +++ b/auto-configurations/models/spring-ai-stability-ai-spring-boot-autoconfigure/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.autoconfigure.stabilityai.StabilityAiImageAutoConfiguration diff --git a/auto-configurations/models/spring-ai-stability-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/stabilityai/StabilityAiAutoConfigurationIT.java b/auto-configurations/models/spring-ai-stability-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/stabilityai/StabilityAiAutoConfigurationIT.java new file mode 100644 index 00000000000..ca7c38069e4 --- /dev/null +++ b/auto-configurations/models/spring-ai-stability-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/stabilityai/StabilityAiAutoConfigurationIT.java @@ -0,0 +1,63 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.stabilityai; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import org.springframework.ai.image.Image; +import org.springframework.ai.image.ImageGeneration; +import org.springframework.ai.image.ImageModel; +import org.springframework.ai.image.ImagePrompt; +import org.springframework.ai.image.ImageResponse; +import org.springframework.ai.stabilityai.StyleEnum; +import org.springframework.ai.stabilityai.api.StabilityAiImageOptions; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +@EnabledIfEnvironmentVariable(named = "STABILITYAI_API_KEY", matches = ".*") +public class StabilityAiAutoConfigurationIT { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.stabilityai.image.api-key=" + System.getenv("STABILITYAI_API_KEY")) + .withConfiguration(AutoConfigurations.of(StabilityAiImageAutoConfiguration.class)); + + @Test + void generate() { + this.contextRunner.run(context -> { + ImageModel imageModel = context.getBean(ImageModel.class); + StabilityAiImageOptions imageOptions = StabilityAiImageOptions.builder() + .stylePreset(StyleEnum.PHOTOGRAPHIC) + .build(); + + var instructions = """ + A light cream colored mini golden doodle. + """; + + ImagePrompt imagePrompt = new ImagePrompt(instructions, imageOptions); + ImageResponse imageResponse = imageModel.call(imagePrompt); + + ImageGeneration imageGeneration = imageResponse.getResult(); + Image image = imageGeneration.getOutput(); + + assertThat(image.getB64Json()).isNotEmpty(); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-stability-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/stabilityai/StabilityAiImagePropertiesTests.java b/auto-configurations/models/spring-ai-stability-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/stabilityai/StabilityAiImagePropertiesTests.java new file mode 100644 index 00000000000..606dbace005 --- /dev/null +++ b/auto-configurations/models/spring-ai-stability-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/stabilityai/StabilityAiImagePropertiesTests.java @@ -0,0 +1,110 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.stabilityai; + +import org.junit.jupiter.api.Test; + +import org.springframework.ai.stabilityai.StabilityAiImageModel; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Christian Tzolov + * @since 0.8.0 + */ +public class StabilityAiImagePropertiesTests { + + @Test + public void chatPropertiesTest() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.stabilityai.image.api-key=API_KEY", + "spring.ai.stabilityai.image.base-url=ENDPOINT", + "spring.ai.stabilityai.image.options.n=10", + "spring.ai.stabilityai.image.options.model=MODEL_XYZ", + "spring.ai.stabilityai.image.options.width=512", + "spring.ai.stabilityai.image.options.height=256", + "spring.ai.stabilityai.image.options.response-format=application/json", + "spring.ai.stabilityai.image.options.n=4", + "spring.ai.stabilityai.image.options.cfg-scale=7", + "spring.ai.stabilityai.image.options.clip-guidance-preset=SIMPLE", + "spring.ai.stabilityai.image.options.sampler=K_EULER", + "spring.ai.stabilityai.image.options.seed=0", + "spring.ai.stabilityai.image.options.steps=30", + "spring.ai.stabilityai.image.options.style-preset=neon-punk" + ) + // @formatter:on + .withConfiguration(AutoConfigurations.of(StabilityAiImageAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(StabilityAiImageProperties.class); + + assertThat(chatProperties.getBaseUrl()).isEqualTo("ENDPOINT"); + assertThat(chatProperties.getApiKey()).isEqualTo("API_KEY"); + assertThat(chatProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + + assertThat(chatProperties.getOptions().getWidth()).isEqualTo(512); + assertThat(chatProperties.getOptions().getHeight()).isEqualTo(256); + assertThat(chatProperties.getOptions().getResponseFormat()).isEqualTo("application/json"); + assertThat(chatProperties.getOptions().getN()).isEqualTo(4); + assertThat(chatProperties.getOptions().getCfgScale()).isEqualTo(7); + assertThat(chatProperties.getOptions().getClipGuidancePreset()).isEqualTo("SIMPLE"); + assertThat(chatProperties.getOptions().getSampler()).isEqualTo("K_EULER"); + assertThat(chatProperties.getOptions().getSeed()).isEqualTo(0); + assertThat(chatProperties.getOptions().getSteps()).isEqualTo(30); + assertThat(chatProperties.getOptions().getStylePreset()).isEqualTo("neon-punk"); + }); + } + + @Test + void stabilityImageActivation() { + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.stabilityai.image.api-key=API_KEY", + "spring.ai.stabilityai.image.base-url=ENDPOINT", "spring.ai.stabilityai.image.enabled=false") + .withConfiguration(AutoConfigurations.of(StabilityAiImageAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(StabilityAiImageProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(StabilityAiImageModel.class)).isEmpty(); + + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.stabilityai.image.api-key=API_KEY", + "spring.ai.stabilityai.image.base-url=ENDPOINT") + .withConfiguration(AutoConfigurations.of(StabilityAiImageAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(StabilityAiImageProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(StabilityAiImageModel.class)).isNotEmpty(); + + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.stabilityai.image.api-key=API_KEY", + "spring.ai.stabilityai.image.base-url=ENDPOINT", "spring.ai.stabilityai.image.enabled=true") + .withConfiguration(AutoConfigurations.of(StabilityAiImageAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(StabilityAiImageProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(StabilityAiImageModel.class)).isNotEmpty(); + + }); + + } + +} diff --git a/auto-configurations/models/spring-ai-transformers-spring-boot-autoconfigure/pom.xml b/auto-configurations/models/spring-ai-transformers-spring-boot-autoconfigure/pom.xml new file mode 100644 index 00000000000..55003e63bf3 --- /dev/null +++ b/auto-configurations/models/spring-ai-transformers-spring-boot-autoconfigure/pom.xml @@ -0,0 +1,86 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../../pom.xml + + spring-ai-transformers-spring-boot-autoconfigure + jar + Spring AI ONNX Transformers Auto Configuration + Spring AI ONNX Transformers 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-transformers + ${project.parent.version} + true + + + + + + org.springframework.ai + spring-ai-chat-client-spring-boot-autoconfigure + ${project.parent.version} + true + + + + org.springframework.ai + spring-ai-retry-spring-boot-autoconfigure + ${project.parent.version} + true + + + + + org.springframework.boot + spring-boot-starter + true + + + + org.springframework.boot + spring-boot-configuration-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-transformers-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/transformers/TransformersEmbeddingModelAutoConfiguration.java b/auto-configurations/models/spring-ai-transformers-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/transformers/TransformersEmbeddingModelAutoConfiguration.java new file mode 100644 index 00000000000..30652cf3bff --- /dev/null +++ b/auto-configurations/models/spring-ai-transformers-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/transformers/TransformersEmbeddingModelAutoConfiguration.java @@ -0,0 +1,71 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.transformers; + +import ai.djl.huggingface.tokenizers.HuggingFaceTokenizer; +import ai.onnxruntime.OrtSession; +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.ai.embedding.observation.EmbeddingModelObservationConvention; +import org.springframework.ai.transformers.TransformersEmbeddingModel; +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.context.annotation.Bean; + +/** + * {@link AutoConfiguration Auto-configuration} for Transformers Embedding Model. + * + * @author Christian Tzolov + */ +@AutoConfiguration +@EnableConfigurationProperties({ TransformersEmbeddingModelProperties.class }) +@ConditionalOnClass({ OrtSession.class, HuggingFaceTokenizer.class, TransformersEmbeddingModel.class }) +public class TransformersEmbeddingModelAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = TransformersEmbeddingModelProperties.CONFIG_PREFIX, name = "enabled", + havingValue = "true", matchIfMissing = true) + public TransformersEmbeddingModel embeddingModel(TransformersEmbeddingModelProperties properties, + ObjectProvider observationRegistry, + ObjectProvider observationConvention) { + + TransformersEmbeddingModel embeddingModel = new TransformersEmbeddingModel(properties.getMetadataMode(), + observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)); + + embeddingModel.setDisableCaching(!properties.getCache().isEnabled()); + embeddingModel.setResourceCacheDirectory(properties.getCache().getDirectory()); + + embeddingModel.setTokenizerResource(properties.getTokenizer().getUri()); + embeddingModel.setTokenizerOptions(properties.getTokenizer().getOptions()); + + embeddingModel.setModelResource(properties.getOnnx().getModelUri()); + + embeddingModel.setGpuDeviceId(properties.getOnnx().getGpuDeviceId()); + + embeddingModel.setModelOutputName(properties.getOnnx().getModelOutputName()); + + observationConvention.ifAvailable(embeddingModel::setObservationConvention); + + return embeddingModel; + } + +} diff --git a/auto-configurations/models/spring-ai-transformers-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/transformers/TransformersEmbeddingModelProperties.java b/auto-configurations/models/spring-ai-transformers-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/transformers/TransformersEmbeddingModelProperties.java new file mode 100644 index 00000000000..6f090aaa28e --- /dev/null +++ b/auto-configurations/models/spring-ai-transformers-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/transformers/TransformersEmbeddingModelProperties.java @@ -0,0 +1,219 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.transformers; + +import java.io.File; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import ai.djl.huggingface.tokenizers.HuggingFaceTokenizer; + +import org.springframework.ai.document.Document; +import org.springframework.ai.document.MetadataMode; +import org.springframework.ai.transformers.TransformersEmbeddingModel; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * Configuration properties for the Transformer Embedding model. + * + * @author Christian Tzolov + */ +@ConfigurationProperties(org.springframework.ai.autoconfigure.transformers.TransformersEmbeddingModelProperties.CONFIG_PREFIX) +public class TransformersEmbeddingModelProperties { + + public static final String CONFIG_PREFIX = "spring.ai.embedding.transformer"; + + public static final String DEFAULT_CACHE_DIRECTORY = new File(System.getProperty("java.io.tmpdir"), + "spring-ai-onnx-generative") + .getAbsolutePath(); + + @NestedConfigurationProperty + private final Tokenizer tokenizer = new Tokenizer(); + + /** + * Controls caching of remote, large resources to local file system. + */ + @NestedConfigurationProperty + private final Cache cache = new Cache(); + + @NestedConfigurationProperty + private final Onnx onnx = new Onnx(); + + /** + * Enable the Transformer Embedding model. + */ + private boolean enabled = true; + + /** + * Specifies what parts of the {@link Document}'s content and metadata will be used + * for computing the embeddings. Applicable for the + * {@link TransformersEmbeddingModel#embed(Document)} method only. Has no effect on + * the {@link TransformersEmbeddingModel#embed(String)} or + * {@link TransformersEmbeddingModel#embed(List)}. Defaults to + * {@link MetadataMode#NONE}. + */ + private MetadataMode metadataMode = MetadataMode.NONE; + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public Cache getCache() { + return this.cache; + } + + public Onnx getOnnx() { + return this.onnx; + } + + public Tokenizer getTokenizer() { + return this.tokenizer; + } + + public MetadataMode getMetadataMode() { + return this.metadataMode; + } + + public void setMetadataMode(MetadataMode metadataMode) { + this.metadataMode = metadataMode; + } + + /** + * Configurations for the {@link HuggingFaceTokenizer} used to convert sentences into + * tokens. + */ + public static class Tokenizer { + + /** + * URI of a pre-trained HuggingFaceTokenizer created by the ONNX engine (e.g. + * tokenizer.json). + */ + private String uri = TransformersEmbeddingModel.DEFAULT_ONNX_TOKENIZER_URI; + + /** + * HuggingFaceTokenizer options such as 'addSpecialTokens', 'modelMaxLength', + * 'truncation', 'padding', 'maxLength', 'stride' and 'padToMultipleOf'. Leave + * empty to fall back to the defaults. + */ + @NestedConfigurationProperty + private Map options = new HashMap<>(); + + public String getUri() { + return this.uri; + } + + public void setUri(String uri) { + this.uri = uri; + } + + public Map getOptions() { + return this.options; + } + + public void setOptions(Map options) { + this.options = options; + } + + } + + public static class Cache { + + /** + * Enable the Resource caching. + */ + private boolean enabled = true; + + /** + * Resource cache directory. Used to cache remote resources, such as the ONNX + * models, to the local file system. Applicable only for cache.enabled == true. + * Defaults to {java.io.tmpdir}/spring-ai-onnx-generative. + */ + private String directory = DEFAULT_CACHE_DIRECTORY; + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getDirectory() { + return this.directory; + } + + public void setDirectory(String directory) { + this.directory = directory; + } + + } + + public static class Onnx { + + /** + * Existing, pre-trained ONNX generative. Commonly exported from + * https://sbert.net/docs/pretrained_models.html. Defaults to + * sentence-transformers/all-MiniLM-L6-v2. + */ + private String modelUri = TransformersEmbeddingModel.DEFAULT_ONNX_MODEL_URI; + + /** + * Defaults to: 'last_hidden_state'. + */ + private String modelOutputName = TransformersEmbeddingModel.DEFAULT_MODEL_OUTPUT_NAME; + + /** + * Run on a GPU or with another provider (optional). + * https://onnxruntime.ai/docs/get-started/with-java.html#run-on-a-gpu-or-with-another-provider-optional + * + * The GPU device ID to execute on. Only applicable if >= 0. Ignored otherwise. + */ + private int gpuDeviceId = -1; + + public String getModelUri() { + return this.modelUri; + } + + public void setModelUri(String modelUri) { + this.modelUri = modelUri; + } + + public int getGpuDeviceId() { + return this.gpuDeviceId; + } + + public void setGpuDeviceId(int gpuDeviceId) { + this.gpuDeviceId = gpuDeviceId; + } + + public String getModelOutputName() { + return this.modelOutputName; + } + + public void setModelOutputName(String modelOutputName) { + this.modelOutputName = modelOutputName; + } + + } + +} diff --git a/auto-configurations/models/spring-ai-transformers-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/models/spring-ai-transformers-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..d64f9554e4a --- /dev/null +++ b/auto-configurations/models/spring-ai-transformers-spring-boot-autoconfigure/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.autoconfigure.transformers.TransformersEmbeddingModelAutoConfiguration diff --git a/auto-configurations/models/spring-ai-transformers-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/transformers/TransformersEmbeddingModelAutoConfigurationIT.java b/auto-configurations/models/spring-ai-transformers-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/transformers/TransformersEmbeddingModelAutoConfigurationIT.java new file mode 100644 index 00000000000..9b3a597bed9 --- /dev/null +++ b/auto-configurations/models/spring-ai-transformers-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/transformers/TransformersEmbeddingModelAutoConfigurationIT.java @@ -0,0 +1,112 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.transformers; + +import java.io.File; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.transformers.TransformersEmbeddingModel; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Christian Tzolov + */ +public class TransformersEmbeddingModelAutoConfigurationIT { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(TransformersEmbeddingModelAutoConfiguration.class)); + + @TempDir + File tempDir; + + @Test + public void embedding() { + this.contextRunner.run(context -> { + var properties = context.getBean(TransformersEmbeddingModelProperties.class); + assertThat(properties.getCache().isEnabled()).isTrue(); + assertThat(properties.getCache().getDirectory()).isEqualTo( + new File(System.getProperty("java.io.tmpdir"), "spring-ai-onnx-generative").getAbsolutePath()); + + EmbeddingModel embeddingModel = context.getBean(EmbeddingModel.class); + assertThat(embeddingModel).isInstanceOf(TransformersEmbeddingModel.class); + + List embeddings = embeddingModel.embed(List.of("Spring Framework", "Spring AI")); + + assertThat(embeddings.size()).isEqualTo(2); // batch size + assertThat(embeddings.get(0).length).isEqualTo(embeddingModel.dimensions()); // dimensions + // size + }); + } + + @Test + public void remoteOnnxModel() { + // https://huggingface.co/intfloat/e5-small-v2 + this.contextRunner.withPropertyValues( + "spring.ai.embedding.transformer.cache.directory=" + this.tempDir.getAbsolutePath(), + "spring.ai.embedding.transformer.onnx.modelUri=https://huggingface.co/intfloat/e5-small-v2/resolve/main/model.onnx", + "spring.ai.embedding.transformer.tokenizer.uri=https://huggingface.co/intfloat/e5-small-v2/raw/main/tokenizer.json") + .run(context -> { + var properties = context.getBean(TransformersEmbeddingModelProperties.class); + assertThat(properties.getOnnx().getModelUri()) + .isEqualTo("https://huggingface.co/intfloat/e5-small-v2/resolve/main/model.onnx"); + assertThat(properties.getTokenizer().getUri()) + .isEqualTo("https://huggingface.co/intfloat/e5-small-v2/raw/main/tokenizer.json"); + + assertThat(properties.getCache().isEnabled()).isTrue(); + assertThat(properties.getCache().getDirectory()).isEqualTo(this.tempDir.getAbsolutePath()); + assertThat(this.tempDir.listFiles()).hasSize(2); + + EmbeddingModel embeddingModel = context.getBean(EmbeddingModel.class); + assertThat(embeddingModel).isInstanceOf(TransformersEmbeddingModel.class); + + assertThat(embeddingModel.dimensions()).isEqualTo(384); + + List embeddings = embeddingModel.embed(List.of("Spring Framework", "Spring AI")); + + assertThat(embeddings.size()).isEqualTo(2); // batch size + assertThat(embeddings.get(0).length).isEqualTo(embeddingModel.dimensions()); // dimensions + // size + }); + } + + @Test + void embeddingActivation() { + this.contextRunner.withPropertyValues("spring.ai.embedding.transformer.enabled=false").run(context -> { + assertThat(context.getBeansOfType(TransformersEmbeddingModelProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(TransformersEmbeddingModel.class)).isEmpty(); + }); + + this.contextRunner.withPropertyValues("spring.ai.embedding.transformer.enabled=true").run(context -> { + assertThat(context.getBeansOfType(TransformersEmbeddingModelProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(TransformersEmbeddingModel.class)).isNotEmpty(); + }); + + this.contextRunner.run(context -> { + assertThat(context.getBeansOfType(TransformersEmbeddingModelProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(TransformersEmbeddingModel.class)).isNotEmpty(); + }); + + } + +} diff --git a/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/pom.xml b/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/pom.xml new file mode 100644 index 00000000000..0f0549f27e6 --- /dev/null +++ b/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/pom.xml @@ -0,0 +1,114 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../../pom.xml + + spring-ai-vertex-ai-spring-boot-autoconfigure + jar + Spring AI Vertex AI Auto Configuration + Spring AI Vertex AI 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-vertex-ai-embedding + ${project.parent.version} + true + + + + + org.springframework.ai + spring-ai-vertex-ai-gemini + ${project.parent.version} + true + + + + + + org.springframework.ai + spring-ai-chat-client-spring-boot-autoconfigure + ${project.parent.version} + true + + + + org.springframework.ai + spring-ai-chat-model-spring-boot-autoconfigure + ${project.parent.version} + true + + + + org.springframework.ai + spring-ai-retry-spring-boot-autoconfigure + ${project.parent.version} + true + + + + + org.springframework.boot + spring-boot-starter + true + + + + org.springframework.boot + spring-boot-configuration-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 + + + + org.testcontainers + junit-jupiter + test + + + + org.testcontainers + ollama + test + + + + diff --git a/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vertexai/embedding/VertexAiEmbeddingAutoConfiguration.java b/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vertexai/embedding/VertexAiEmbeddingAutoConfiguration.java new file mode 100644 index 00000000000..c6f32025e9d --- /dev/null +++ b/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vertexai/embedding/VertexAiEmbeddingAutoConfiguration.java @@ -0,0 +1,102 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.vertexai.embedding; + +import java.io.IOException; + +import com.google.cloud.vertexai.VertexAI; +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.embedding.observation.EmbeddingModelObservationConvention; +import org.springframework.ai.vertexai.embedding.VertexAiEmbeddingConnectionDetails; +import org.springframework.ai.vertexai.embedding.multimodal.VertexAiMultimodalEmbeddingModel; +import org.springframework.ai.vertexai.embedding.text.VertexAiTextEmbeddingModel; +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.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; + +/** + * Auto-configuration for Vertex AI Gemini Chat. + * + * @author Christian Tzolov + * @author Mark Pollack + * @since 1.0.0 + */ +@AutoConfiguration(after = { SpringAiRetryAutoConfiguration.class }) +@ConditionalOnClass({ VertexAI.class, VertexAiTextEmbeddingModel.class }) +@EnableConfigurationProperties({ VertexAiEmbeddingConnectionProperties.class, VertexAiTextEmbeddingProperties.class, + VertexAiMultimodalEmbeddingProperties.class }) +@ImportAutoConfiguration(classes = { SpringAiRetryAutoConfiguration.class }) +public class VertexAiEmbeddingAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public VertexAiEmbeddingConnectionDetails connectionDetails( + VertexAiEmbeddingConnectionProperties connectionProperties) { + + Assert.hasText(connectionProperties.getProjectId(), "Vertex AI project-id must be set!"); + Assert.hasText(connectionProperties.getLocation(), "Vertex AI location must be set!"); + + var connectionBuilder = VertexAiEmbeddingConnectionDetails.builder() + .projectId(connectionProperties.getProjectId()) + .location(connectionProperties.getLocation()); + + if (StringUtils.hasText(connectionProperties.getApiEndpoint())) { + connectionBuilder.apiEndpoint(connectionProperties.getApiEndpoint()); + } + + return connectionBuilder.build(); + + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = VertexAiTextEmbeddingProperties.CONFIG_PREFIX, name = "enabled", + havingValue = "true", matchIfMissing = true) + public VertexAiTextEmbeddingModel textEmbedding(VertexAiEmbeddingConnectionDetails connectionDetails, + VertexAiTextEmbeddingProperties textEmbeddingProperties, RetryTemplate retryTemplate, + ObjectProvider observationRegistry, + ObjectProvider observationConvention) { + + var embeddingModel = new VertexAiTextEmbeddingModel(connectionDetails, textEmbeddingProperties.getOptions(), + retryTemplate, observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)); + + observationConvention.ifAvailable(embeddingModel::setObservationConvention); + + return embeddingModel; + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = VertexAiMultimodalEmbeddingProperties.CONFIG_PREFIX, name = "enabled", + havingValue = "true", matchIfMissing = true) + public VertexAiMultimodalEmbeddingModel multimodalEmbedding(VertexAiEmbeddingConnectionDetails connectionDetails, + VertexAiMultimodalEmbeddingProperties multimodalEmbeddingProperties) throws IOException { + + return new VertexAiMultimodalEmbeddingModel(connectionDetails, multimodalEmbeddingProperties.getOptions()); + } + +} diff --git a/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vertexai/embedding/VertexAiEmbeddingConnectionProperties.java b/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vertexai/embedding/VertexAiEmbeddingConnectionProperties.java new file mode 100644 index 00000000000..a86462d396d --- /dev/null +++ b/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vertexai/embedding/VertexAiEmbeddingConnectionProperties.java @@ -0,0 +1,85 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.vertexai.embedding; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.core.io.Resource; + +/** + * Configuration properties for Vertex AI Embedding. + * + * @author Christian Tzolov + * @since 1.0.0 + */ +@ConfigurationProperties(VertexAiEmbeddingConnectionProperties.CONFIG_PREFIX) +public class VertexAiEmbeddingConnectionProperties { + + public static final String CONFIG_PREFIX = "spring.ai.vertex.ai.embedding"; + + /** + * Vertex AI Gemini project ID. + */ + private String projectId; + + /** + * Vertex AI Gemini location. + */ + private String location; + + /** + * URI to Vertex AI Gemini credentials (optional) + */ + private Resource credentialsUri; + + /** + * Vertex AI Gemini API endpoint. + */ + private String apiEndpoint; + + public String getProjectId() { + return this.projectId; + } + + public void setProjectId(String projectId) { + this.projectId = projectId; + } + + public String getLocation() { + return this.location; + } + + public void setLocation(String location) { + this.location = location; + } + + public Resource getCredentialsUri() { + return this.credentialsUri; + } + + public void setCredentialsUri(Resource credentialsUri) { + this.credentialsUri = credentialsUri; + } + + public String getApiEndpoint() { + return this.apiEndpoint; + } + + public void setApiEndpoint(String apiEndpoint) { + this.apiEndpoint = apiEndpoint; + } + +} diff --git a/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vertexai/embedding/VertexAiMultimodalEmbeddingProperties.java b/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vertexai/embedding/VertexAiMultimodalEmbeddingProperties.java new file mode 100644 index 00000000000..912084bd5b1 --- /dev/null +++ b/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vertexai/embedding/VertexAiMultimodalEmbeddingProperties.java @@ -0,0 +1,58 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.vertexai.embedding; + +import org.springframework.ai.vertexai.embedding.multimodal.VertexAiMultimodalEmbeddingOptions; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration properties for Vertex AI Gemini Chat. + * + * @author Christian Tzolov + * @since 1.0.0 + */ +@ConfigurationProperties(VertexAiMultimodalEmbeddingProperties.CONFIG_PREFIX) +public class VertexAiMultimodalEmbeddingProperties { + + public static final String CONFIG_PREFIX = "spring.ai.vertex.ai.embedding.multimodal"; + + private boolean enabled = true; + + /** + * Vertex AI Text Embedding API options. + */ + private VertexAiMultimodalEmbeddingOptions options = VertexAiMultimodalEmbeddingOptions.builder() + .model(VertexAiMultimodalEmbeddingOptions.DEFAULT_MODEL_NAME) + .build(); + + public VertexAiMultimodalEmbeddingOptions getOptions() { + return this.options; + } + + public void setOptions(VertexAiMultimodalEmbeddingOptions options) { + this.options = options; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + +} diff --git a/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vertexai/embedding/VertexAiTextEmbeddingProperties.java b/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vertexai/embedding/VertexAiTextEmbeddingProperties.java new file mode 100644 index 00000000000..7addfab95cb --- /dev/null +++ b/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vertexai/embedding/VertexAiTextEmbeddingProperties.java @@ -0,0 +1,59 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.vertexai.embedding; + +import org.springframework.ai.vertexai.embedding.text.VertexAiTextEmbeddingOptions; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration properties for Vertex AI Gemini Chat. + * + * @author Christian Tzolov + * @since 1.0.0 + */ +@ConfigurationProperties(VertexAiTextEmbeddingProperties.CONFIG_PREFIX) +public class VertexAiTextEmbeddingProperties { + + public static final String CONFIG_PREFIX = "spring.ai.vertex.ai.embedding.text"; + + private boolean enabled = true; + + /** + * Vertex AI Text Embedding API options. + */ + private VertexAiTextEmbeddingOptions options = VertexAiTextEmbeddingOptions.builder() + .taskType(VertexAiTextEmbeddingOptions.TaskType.RETRIEVAL_DOCUMENT) + .model(VertexAiTextEmbeddingOptions.DEFAULT_MODEL_NAME) + .build(); + + public VertexAiTextEmbeddingOptions getOptions() { + return this.options; + } + + public void setOptions(VertexAiTextEmbeddingOptions options) { + this.options = options; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + +} diff --git a/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vertexai/gemini/VertexAiGeminiAutoConfiguration.java b/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vertexai/gemini/VertexAiGeminiAutoConfiguration.java new file mode 100644 index 00000000000..69b4321d8d9 --- /dev/null +++ b/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vertexai/gemini/VertexAiGeminiAutoConfiguration.java @@ -0,0 +1,125 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.vertexai.gemini; + +import java.io.IOException; +import java.util.List; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.cloud.vertexai.VertexAI; +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.ai.autoconfigure.chat.model.ToolCallingAutoConfiguration; +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.chat.observation.ChatModelObservationConvention; +import org.springframework.ai.model.function.DefaultFunctionCallbackResolver; +import org.springframework.ai.model.function.FunctionCallback; +import org.springframework.ai.model.function.FunctionCallback.SchemaType; +import org.springframework.ai.model.function.FunctionCallbackResolver; +import org.springframework.ai.model.tool.ToolCallingManager; +import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatModel; +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.context.properties.EnableConfigurationProperties; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; + +/** + * Auto-configuration for Vertex AI Gemini Chat. + * + * @author Christian Tzolov + * @author Soby Chacko + * @author Mark Pollack + * @since 1.0.0 + */ +@AutoConfiguration(after = { SpringAiRetryAutoConfiguration.class, ToolCallingAutoConfiguration.class }) +@ConditionalOnClass({ VertexAI.class, VertexAiGeminiChatModel.class }) +@EnableConfigurationProperties({ VertexAiGeminiChatProperties.class, VertexAiGeminiConnectionProperties.class }) +@ImportAutoConfiguration(classes = { SpringAiRetryAutoConfiguration.class, ToolCallingAutoConfiguration.class }) +public class VertexAiGeminiAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public VertexAI vertexAi(VertexAiGeminiConnectionProperties connectionProperties) throws IOException { + + Assert.hasText(connectionProperties.getProjectId(), "Vertex AI project-id must be set!"); + Assert.hasText(connectionProperties.getLocation(), "Vertex AI location must be set!"); + Assert.notNull(connectionProperties.getTransport(), "Vertex AI transport must be set!"); + + var vertexAIBuilder = new VertexAI.Builder().setProjectId(connectionProperties.getProjectId()) + .setLocation(connectionProperties.getLocation()) + .setTransport(com.google.cloud.vertexai.Transport.valueOf(connectionProperties.getTransport().name())); + + if (StringUtils.hasText(connectionProperties.getApiEndpoint())) { + vertexAIBuilder.setApiEndpoint(connectionProperties.getApiEndpoint()); + } + if (!CollectionUtils.isEmpty(connectionProperties.getScopes())) { + vertexAIBuilder.setScopes(connectionProperties.getScopes()); + } + + if (connectionProperties.getCredentialsUri() != null) { + GoogleCredentials credentials = GoogleCredentials + .fromStream(connectionProperties.getCredentialsUri().getInputStream()); + + vertexAIBuilder.setCredentials(credentials); + } + return vertexAIBuilder.build(); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = VertexAiGeminiChatProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + public VertexAiGeminiChatModel vertexAiGeminiChat(VertexAI vertexAi, VertexAiGeminiChatProperties chatProperties, + ToolCallingManager toolCallingManager, ApplicationContext context, RetryTemplate retryTemplate, + ObjectProvider observationRegistry, + ObjectProvider observationConvention) { + + VertexAiGeminiChatModel chatModel = VertexAiGeminiChatModel.builder() + .vertexAI(vertexAi) + .defaultOptions(chatProperties.getOptions()) + .toolCallingManager(toolCallingManager) + .retryTemplate(retryTemplate) + .observationRegistry(observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)) + .build(); + + observationConvention.ifAvailable(chatModel::setObservationConvention); + + return chatModel; + } + + /** + * Because of the OPEN_API_SCHEMA type, the FunctionCallbackResolver instance must + * different from the other JSON schema types. + */ + // private FunctionCallbackResolver springAiFunctionManager(ApplicationContext + // context) { + // DefaultFunctionCallbackResolver manager = new DefaultFunctionCallbackResolver(); + // manager.setSchemaType(SchemaType.OPEN_API_SCHEMA); + // manager.setApplicationContext(context); + // return manager; + // } + +} diff --git a/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vertexai/gemini/VertexAiGeminiChatProperties.java b/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vertexai/gemini/VertexAiGeminiChatProperties.java new file mode 100644 index 00000000000..9ce224a7a8c --- /dev/null +++ b/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vertexai/gemini/VertexAiGeminiChatProperties.java @@ -0,0 +1,53 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.vertexai.gemini; + +import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatModel; +import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatOptions; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration properties for Vertex AI Gemini Chat. + * + * @author Christian Tzolov + * @since 0.8.0 + */ +@ConfigurationProperties(VertexAiGeminiChatProperties.CONFIG_PREFIX) +public class VertexAiGeminiChatProperties { + + public static final String CONFIG_PREFIX = "spring.ai.vertex.ai.gemini.chat"; + + public static final String DEFAULT_MODEL = VertexAiGeminiChatModel.ChatModel.GEMINI_2_0_FLASH.getValue(); + + /** + * Vertex AI Gemini API generative options. + */ + private VertexAiGeminiChatOptions options = VertexAiGeminiChatOptions.builder() + .temperature(0.7) + .candidateCount(1) + .model(DEFAULT_MODEL) + .build(); + + public VertexAiGeminiChatOptions getOptions() { + return this.options; + } + + public void setOptions(VertexAiGeminiChatOptions options) { + this.options = options; + } + +} diff --git a/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vertexai/gemini/VertexAiGeminiConnectionProperties.java b/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vertexai/gemini/VertexAiGeminiConnectionProperties.java new file mode 100644 index 00000000000..e47d41863f1 --- /dev/null +++ b/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vertexai/gemini/VertexAiGeminiConnectionProperties.java @@ -0,0 +1,122 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.vertexai.gemini; + +import java.util.List; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.core.io.Resource; + +/** + * Configuration properties for Vertex AI Gemini Chat. + * + * @author Christian Tzolov + * @since 0.8.0 + */ +@ConfigurationProperties(VertexAiGeminiConnectionProperties.CONFIG_PREFIX) +public class VertexAiGeminiConnectionProperties { + + public static final String CONFIG_PREFIX = "spring.ai.vertex.ai.gemini"; + + /** + * Vertex AI Gemini project ID. + */ + private String projectId; + + /** + * Vertex AI Gemini location. + */ + private String location; + + /** + * URI to Vertex AI Gemini credentials (optional) + */ + private Resource credentialsUri; + + /** + * Vertex AI Gemini API endpoint. + */ + private String apiEndpoint; + + /** + * + */ + private List scopes = List.of(); + + private Transport transport = Transport.GRPC; + + public String getProjectId() { + return this.projectId; + } + + public void setProjectId(String projectId) { + this.projectId = projectId; + } + + public String getLocation() { + return this.location; + } + + public void setLocation(String location) { + this.location = location; + } + + public Resource getCredentialsUri() { + return this.credentialsUri; + } + + public void setCredentialsUri(Resource credentialsUri) { + this.credentialsUri = credentialsUri; + } + + public String getApiEndpoint() { + return this.apiEndpoint; + } + + public void setApiEndpoint(String apiEndpoint) { + this.apiEndpoint = apiEndpoint; + } + + public List getScopes() { + return this.scopes; + } + + public void setScopes(List scopes) { + this.scopes = scopes; + } + + public Transport getTransport() { + return this.transport; + } + + public void setTransport(Transport transport) { + this.transport = transport; + } + + public enum Transport { + + /** When used, the clients will send REST requests to the backing service. */ + REST, + /** + * When used, the clients will send gRPC to the backing service. This is usually + * more efficient and is the default transport. + */ + GRPC + + } + +} diff --git a/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..c5f9bc953cf --- /dev/null +++ b/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,17 @@ +# +# 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.autoconfigure.vertexai.gemini.VertexAiGeminiAutoConfiguration +org.springframework.ai.autoconfigure.vertexai.embedding.VertexAiEmbeddingAutoConfiguration diff --git a/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vertexai/embedding/VertexAiTextEmbeddingModelAutoConfigurationIT.java b/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vertexai/embedding/VertexAiTextEmbeddingModelAutoConfigurationIT.java new file mode 100644 index 00000000000..9268b92f6df --- /dev/null +++ b/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vertexai/embedding/VertexAiTextEmbeddingModelAutoConfigurationIT.java @@ -0,0 +1,144 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.vertexai.embedding; + +import java.io.File; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.ai.document.Document; +import org.springframework.ai.embedding.DocumentEmbeddingRequest; +import org.springframework.ai.embedding.EmbeddingOptionsBuilder; +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.ai.embedding.EmbeddingResultMetadata; +import org.springframework.ai.vertexai.embedding.multimodal.VertexAiMultimodalEmbeddingModel; +import org.springframework.ai.vertexai.embedding.text.VertexAiTextEmbeddingModel; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Christian Tzolov + */ +@EnabledIfEnvironmentVariable(named = "VERTEX_AI_GEMINI_PROJECT_ID", matches = ".*") +@EnabledIfEnvironmentVariable(named = "VERTEX_AI_GEMINI_LOCATION", matches = ".*") +public class VertexAiTextEmbeddingModelAutoConfigurationIT { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.vertex.ai.embedding.project-id=" + System.getenv("VERTEX_AI_GEMINI_PROJECT_ID"), + "spring.ai.vertex.ai.embedding.location=" + System.getenv("VERTEX_AI_GEMINI_LOCATION")) + .withConfiguration(AutoConfigurations.of(VertexAiEmbeddingAutoConfiguration.class)); + + @TempDir + File tempDir; + + @Test + public void textEmbedding() { + this.contextRunner.run(context -> { + var conntectionProperties = context.getBean(VertexAiEmbeddingConnectionProperties.class); + var textEmbeddingProperties = context.getBean(VertexAiTextEmbeddingProperties.class); + + assertThat(conntectionProperties).isNotNull(); + assertThat(textEmbeddingProperties.isEnabled()).isTrue(); + + VertexAiTextEmbeddingModel embeddingModel = context.getBean(VertexAiTextEmbeddingModel.class); + assertThat(embeddingModel).isInstanceOf(VertexAiTextEmbeddingModel.class); + + List embeddings = embeddingModel.embed(List.of("Spring Framework", "Spring AI")); + + assertThat(embeddings.size()).isEqualTo(2); // batch size + assertThat(embeddings.get(0).length).isEqualTo(embeddingModel.dimensions()); + }); + } + + @Test + void textEmbeddingActivation() { + this.contextRunner.withPropertyValues("spring.ai.vertex.ai.embedding.text.enabled=false").run(context -> { + assertThat(context.getBeansOfType(VertexAiTextEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(VertexAiTextEmbeddingModel.class)).isEmpty(); + }); + + this.contextRunner.withPropertyValues("spring.ai.vertex.ai.embedding.text.enabled=true").run(context -> { + assertThat(context.getBeansOfType(VertexAiTextEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(VertexAiTextEmbeddingModel.class)).isNotEmpty(); + }); + + this.contextRunner.run(context -> { + assertThat(context.getBeansOfType(VertexAiTextEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(VertexAiTextEmbeddingModel.class)).isNotEmpty(); + }); + + } + + @Test + public void multimodalEmbedding() { + this.contextRunner.run(context -> { + var conntectionProperties = context.getBean(VertexAiEmbeddingConnectionProperties.class); + var multimodalEmbeddingProperties = context.getBean(VertexAiMultimodalEmbeddingProperties.class); + + assertThat(conntectionProperties).isNotNull(); + assertThat(multimodalEmbeddingProperties.isEnabled()).isTrue(); + + VertexAiMultimodalEmbeddingModel multiModelEmbeddingModel = context + .getBean(VertexAiMultimodalEmbeddingModel.class); + + assertThat(multiModelEmbeddingModel).isNotNull(); + + var document = new Document("Hello World"); + + DocumentEmbeddingRequest embeddingRequest = new DocumentEmbeddingRequest(List.of(document), + EmbeddingOptionsBuilder.builder().build()); + + EmbeddingResponse embeddingResponse = multiModelEmbeddingModel.call(embeddingRequest); + assertThat(embeddingResponse.getResults()).hasSize(1); + assertThat(embeddingResponse.getResults().get(0)).isNotNull(); + assertThat(embeddingResponse.getResults().get(0).getMetadata().getModalityType()) + .isEqualTo(EmbeddingResultMetadata.ModalityType.TEXT); + assertThat(embeddingResponse.getResults().get(0).getOutput()).hasSize(1408); + + assertThat(embeddingResponse.getMetadata().getModel()).isEqualTo("multimodalembedding@001"); + assertThat(embeddingResponse.getMetadata().getUsage().getPromptTokens()).isEqualTo(0); + + assertThat(multiModelEmbeddingModel.dimensions()).isEqualTo(1408); + + }); + } + + @Test + void multimodalEmbeddingActivation() { + this.contextRunner.withPropertyValues("spring.ai.vertex.ai.embedding.multimodal.enabled=false").run(context -> { + assertThat(context.getBeansOfType(VertexAiMultimodalEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(VertexAiMultimodalEmbeddingModel.class)).isEmpty(); + }); + + this.contextRunner.withPropertyValues("spring.ai.vertex.ai.embedding.multimodal.enabled=true").run(context -> { + assertThat(context.getBeansOfType(VertexAiMultimodalEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(VertexAiMultimodalEmbeddingModel.class)).isNotEmpty(); + }); + + this.contextRunner.run(context -> { + assertThat(context.getBeansOfType(VertexAiMultimodalEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(VertexAiMultimodalEmbeddingModel.class)).isNotEmpty(); + }); + + } + +} diff --git a/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vertexai/gemini/VertexAiGeminiAutoConfigurationIT.java b/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vertexai/gemini/VertexAiGeminiAutoConfigurationIT.java new file mode 100644 index 00000000000..ca2cdeb3a02 --- /dev/null +++ b/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vertexai/gemini/VertexAiGeminiAutoConfigurationIT.java @@ -0,0 +1,73 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.vertexai.gemini; + +import java.util.stream.Collectors; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import reactor.core.publisher.Flux; + +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatModel; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +@EnabledIfEnvironmentVariable(named = "VERTEX_AI_GEMINI_PROJECT_ID", matches = ".*") +@EnabledIfEnvironmentVariable(named = "VERTEX_AI_GEMINI_LOCATION", matches = ".*") +public class VertexAiGeminiAutoConfigurationIT { + + private static final Log logger = LogFactory.getLog(VertexAiGeminiAutoConfigurationIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.vertex.ai.gemini.project-id=" + System.getenv("VERTEX_AI_GEMINI_PROJECT_ID"), + "spring.ai.vertex.ai.gemini.location=" + System.getenv("VERTEX_AI_GEMINI_LOCATION")) + .withConfiguration(AutoConfigurations.of(VertexAiGeminiAutoConfiguration.class)); + + @Test + void generate() { + this.contextRunner.run(context -> { + VertexAiGeminiChatModel chatModel = context.getBean(VertexAiGeminiChatModel.class); + String response = chatModel.call("Hello"); + assertThat(response).isNotEmpty(); + logger.info("Response: " + response); + }); + } + + @Test + void generateStreaming() { + this.contextRunner.run(context -> { + VertexAiGeminiChatModel chatModel = context.getBean(VertexAiGeminiChatModel.class); + Flux responseFlux = chatModel.stream(new Prompt(new UserMessage("Hello"))); + String response = responseFlux.collectList() + .block() + .stream() + .map(chatResponse -> chatResponse.getResults().get(0).getOutput().getText()) + .collect(Collectors.joining()); + + assertThat(response).isNotEmpty(); + logger.info("Response: " + response); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vertexai/gemini/tool/FunctionCallWithFunctionBeanIT.java b/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vertexai/gemini/tool/FunctionCallWithFunctionBeanIT.java new file mode 100644 index 00000000000..eb31e3b8840 --- /dev/null +++ b/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vertexai/gemini/tool/FunctionCallWithFunctionBeanIT.java @@ -0,0 +1,147 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.vertexai.gemini.tool; + +import java.util.List; +import java.util.function.Function; + +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.autoconfigure.vertexai.gemini.VertexAiGeminiAutoConfiguration; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.model.function.FunctionCallingOptions; +import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatModel; +import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatOptions; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Description; + +import static org.assertj.core.api.Assertions.assertThat; + +@EnabledIfEnvironmentVariable(named = "VERTEX_AI_GEMINI_PROJECT_ID", matches = ".*") +@EnabledIfEnvironmentVariable(named = "VERTEX_AI_GEMINI_LOCATION", matches = ".*") +class FunctionCallWithFunctionBeanIT { + + private final Logger logger = LoggerFactory.getLogger(FunctionCallWithFunctionBeanIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.vertex.ai.gemini.project-id=" + System.getenv("VERTEX_AI_GEMINI_PROJECT_ID"), + "spring.ai.vertex.ai.gemini.location=" + System.getenv("VERTEX_AI_GEMINI_LOCATION")) + + .withConfiguration(AutoConfigurations.of(VertexAiGeminiAutoConfiguration.class)) + .withUserConfiguration(Config.class); + + @Test + void functionCallTest() { + + this.contextRunner.withPropertyValues("spring.ai.vertex.ai.gemini.chat.options.model=" + // + VertexAiGeminiChatModel.ChatModel.GEMINI_PRO_1_5_PRO.getValue()) + + VertexAiGeminiChatModel.ChatModel.GEMINI_2_0_FLASH.getValue()) + .run(context -> { + + VertexAiGeminiChatModel chatModel = context.getBean(VertexAiGeminiChatModel.class); + + var userMessage = new UserMessage(""" + What's the weather like in San Francisco, Paris and in Tokyo? + Return the temperature in Celsius. + Perform multiple funciton execution if necessary. + """); + + ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), + VertexAiGeminiChatOptions.builder().function("weatherFunction").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + + response = chatModel.call(new Prompt(List.of(userMessage), + VertexAiGeminiChatOptions.builder().function("weatherFunction3").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + + response = chatModel + .call(new Prompt(List.of(userMessage), VertexAiGeminiChatOptions.builder().build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).doesNotContain("30", "10", "15"); + + }); + } + + @Test + void functionCallWithPortableFunctionCallingOptions() { + + this.contextRunner.withPropertyValues("spring.ai.vertex.ai.gemini.chat.options.model=" + // + VertexAiGeminiChatModel.ChatModel.GEMINI_PRO_1_5_PRO.getValue()) + + VertexAiGeminiChatModel.ChatModel.GEMINI_2_0_FLASH.getValue()) + .run(context -> { + + VertexAiGeminiChatModel chatModel = context.getBean(VertexAiGeminiChatModel.class); + + var userMessage = new UserMessage(""" + What's the weather like in San Francisco, Paris and in Tokyo? + Return the temperature in Celsius. + Perform multiple funciton execution if necessary. + """); + + ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), + FunctionCallingOptions.builder().function("weatherFunction").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + + response = chatModel.call(new Prompt(List.of(userMessage), + VertexAiGeminiChatOptions.builder().function("weatherFunction3").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + + }); + } + + @Configuration + static class Config { + + @Bean + @Description("Get the weather in location") + public Function weatherFunction() { + return new MockWeatherService(); + } + + // Relies on the Request's JsonClassDescription annotation to provide the + // function description. + @Bean + public Function weatherFunction3() { + MockWeatherService weatherService = new MockWeatherService(); + return (weatherService::apply); + } + + } + +} diff --git a/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vertexai/gemini/tool/FunctionCallWithFunctionWrapperIT.java b/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vertexai/gemini/tool/FunctionCallWithFunctionWrapperIT.java new file mode 100644 index 00000000000..bfcdb7c6433 --- /dev/null +++ b/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vertexai/gemini/tool/FunctionCallWithFunctionWrapperIT.java @@ -0,0 +1,90 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.vertexai.gemini.tool; + +import java.util.List; + +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.autoconfigure.vertexai.gemini.VertexAiGeminiAutoConfiguration; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.function.FunctionToolCallback; +import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatModel; +import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatOptions; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +@EnabledIfEnvironmentVariable(named = "VERTEX_AI_GEMINI_PROJECT_ID", matches = ".*") +@EnabledIfEnvironmentVariable(named = "VERTEX_AI_GEMINI_LOCATION", matches = ".*") +public class FunctionCallWithFunctionWrapperIT { + + private final Logger logger = LoggerFactory.getLogger(FunctionCallWithFunctionWrapperIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.vertex.ai.gemini.project-id=" + System.getenv("VERTEX_AI_GEMINI_PROJECT_ID"), + "spring.ai.vertex.ai.gemini.location=" + System.getenv("VERTEX_AI_GEMINI_LOCATION")) + .withConfiguration(AutoConfigurations.of(VertexAiGeminiAutoConfiguration.class)) + .withUserConfiguration(Config.class); + + @Test + void functionCallTest() { + this.contextRunner + .withPropertyValues("spring.ai.vertex.ai.gemini.chat.options.model=" + + VertexAiGeminiChatModel.ChatModel.GEMINI_2_0_FLASH.getValue()) + .run(context -> { + + VertexAiGeminiChatModel chatModel = context.getBean(VertexAiGeminiChatModel.class); + + var userMessage = new UserMessage(""" + What's the weather like in San Francisco, Paris and in Tokyo? + Return the temperature in Celsius. + """); + + ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), + VertexAiGeminiChatOptions.builder().toolName("WeatherInfo").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + }); + } + + @Configuration + static class Config { + + @Bean + public ToolCallback weatherFunctionInfo() { + + return FunctionToolCallback.builder("WeatherInfo", new MockWeatherService()) + .description("Get the current weather in a given location") + .inputType(MockWeatherService.Request.class) + .build(); + } + + } + +} diff --git a/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vertexai/gemini/tool/FunctionCallWithPromptFunctionIT.java b/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vertexai/gemini/tool/FunctionCallWithPromptFunctionIT.java new file mode 100644 index 00000000000..2227f80c623 --- /dev/null +++ b/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vertexai/gemini/tool/FunctionCallWithPromptFunctionIT.java @@ -0,0 +1,96 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.vertexai.gemini.tool; + +import java.util.List; + +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.autoconfigure.vertexai.gemini.VertexAiGeminiAutoConfiguration; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.model.function.FunctionCallback; +import org.springframework.ai.model.function.FunctionCallback.SchemaType; +import org.springframework.ai.tool.function.FunctionToolCallback; +import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatModel; +import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatOptions; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +@EnabledIfEnvironmentVariable(named = "VERTEX_AI_GEMINI_PROJECT_ID", matches = ".*") +@EnabledIfEnvironmentVariable(named = "VERTEX_AI_GEMINI_LOCATION", matches = ".*") +public class FunctionCallWithPromptFunctionIT { + + private final Logger logger = LoggerFactory.getLogger(FunctionCallWithPromptFunctionIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.vertex.ai.gemini.project-id=" + System.getenv("VERTEX_AI_GEMINI_PROJECT_ID"), + "spring.ai.vertex.ai.gemini.location=" + System.getenv("VERTEX_AI_GEMINI_LOCATION")) + .withConfiguration(AutoConfigurations.of(VertexAiGeminiAutoConfiguration.class)); + + @Test + void functionCallTest() { + this.contextRunner + .withPropertyValues("spring.ai.vertex.ai.gemini.chat.options.model=" + + VertexAiGeminiChatModel.ChatModel.GEMINI_2_0_FLASH_LIGHT.getValue()) + .run(context -> { + + VertexAiGeminiChatModel chatModel = context.getBean(VertexAiGeminiChatModel.class); + + // var systemMessage = new SystemMessage(""" + // Use Multi-turn function calling. + // Answer for all listed locations. + // If the information was not fetched call the function again. Repeat at + // most 3 times. + // """); + var userMessage = new UserMessage(""" + What's the weather like in San Francisco, Paris and in Tokyo? + Return the temperature in Celsius. + """); + + var promptOptions = VertexAiGeminiChatOptions.builder() + .toolCallbacks( + List.of(FunctionToolCallback.builder("CurrentWeatherService", new MockWeatherService()) + .description("Get the weather in location") + .inputType(MockWeatherService.Request.class) + .build())) + .build(); + + ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), promptOptions)); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + + // Verify that no function call is made. + response = chatModel + .call(new Prompt(List.of(userMessage), VertexAiGeminiChatOptions.builder().build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).doesNotContain("30", "10", "15"); + + }); + } + +} diff --git a/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vertexai/gemini/tool/MockWeatherService.java b/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vertexai/gemini/tool/MockWeatherService.java new file mode 100644 index 00000000000..4518a5194f5 --- /dev/null +++ b/auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vertexai/gemini/tool/MockWeatherService.java @@ -0,0 +1,96 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.vertexai.gemini.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; + +/** + * Mock 3rd party weather service. + * + * @author Christian Tzolov + */ +@JsonClassDescription("Get the weather in location") +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/auto-configurations/models/spring-ai-watsonx-ai-spring-boot-autoconfigure/pom.xml b/auto-configurations/models/spring-ai-watsonx-ai-spring-boot-autoconfigure/pom.xml new file mode 100644 index 00000000000..43b7548f7c5 --- /dev/null +++ b/auto-configurations/models/spring-ai-watsonx-ai-spring-boot-autoconfigure/pom.xml @@ -0,0 +1,79 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../../pom.xml + + spring-ai-watsonx-ai-spring-boot-autoconfigure + jar + Spring AI Watsonx AI Auto Configuration + Spring AI Watsonx 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-watsonx-ai + ${project.parent.version} + true + + + + + + org.springframework.ai + spring-ai-chat-client-spring-boot-autoconfigure + ${project.parent.version} + true + + + + + org.springframework.boot + spring-boot-starter + true + + + + org.springframework.boot + spring-boot-configuration-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-watsonx-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAiAutoConfiguration.java b/auto-configurations/models/spring-ai-watsonx-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAiAutoConfiguration.java new file mode 100644 index 00000000000..e5d53314bb6 --- /dev/null +++ b/auto-configurations/models/spring-ai-watsonx-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAiAutoConfiguration.java @@ -0,0 +1,74 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.watsonxai; + +import org.springframework.ai.watsonx.WatsonxAiChatModel; +import org.springframework.ai.watsonx.WatsonxAiEmbeddingModel; +import org.springframework.ai.watsonx.api.WatsonxAiApi; +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.web.client.RestClient; + +/** + * WatsonX.ai autoconfiguration class. + * + * @author Pablo Sanchidrian Herrera + * @author John Jario Moreno Rojas + * @author Christian Tzolov + * @since 1.0.0 + */ +@AutoConfiguration(after = RestClientAutoConfiguration.class) +@ConditionalOnClass(WatsonxAiApi.class) +@EnableConfigurationProperties({ WatsonxAiConnectionProperties.class, WatsonxAiChatProperties.class, + WatsonxAiEmbeddingProperties.class }) +@ConditionalOnProperty(prefix = WatsonxAiChatProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) +public class WatsonxAiAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public WatsonxAiApi watsonxApi(WatsonxAiConnectionProperties properties, + ObjectProvider restClientBuilderProvider) { + return new WatsonxAiApi(properties.getBaseUrl(), properties.getStreamEndpoint(), properties.getTextEndpoint(), + properties.getEmbeddingEndpoint(), properties.getProjectId(), properties.getIAMToken(), + restClientBuilderProvider.getIfAvailable(RestClient::builder)); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = WatsonxAiChatProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + public WatsonxAiChatModel watsonxChatModel(WatsonxAiApi watsonxApi, WatsonxAiChatProperties chatProperties) { + return new WatsonxAiChatModel(watsonxApi, chatProperties.getOptions()); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = WatsonxAiEmbeddingProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + public WatsonxAiEmbeddingModel watsonxAiEmbeddingModel(WatsonxAiApi watsonxApi, + WatsonxAiEmbeddingProperties properties) { + return new WatsonxAiEmbeddingModel(watsonxApi, properties.getOptions()); + } + +} diff --git a/auto-configurations/models/spring-ai-watsonx-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAiChatProperties.java b/auto-configurations/models/spring-ai-watsonx-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAiChatProperties.java new file mode 100644 index 00000000000..fbd14c698c3 --- /dev/null +++ b/auto-configurations/models/spring-ai-watsonx-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAiChatProperties.java @@ -0,0 +1,74 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.watsonxai; + +import java.util.List; + +import org.springframework.ai.watsonx.WatsonxAiChatOptions; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * Chat properties for Watsonx.AI Chat. + * + * @author Christian Tzolov + * @author Alexandros Pappas + * @since 1.0.0 + */ +@ConfigurationProperties(WatsonxAiChatProperties.CONFIG_PREFIX) +public class WatsonxAiChatProperties { + + public static final String CONFIG_PREFIX = "spring.ai.watsonx.ai.chat"; + + /** + * Enable Watsonx.AI chat model. + */ + private boolean enabled = true; + + /** + * Watsonx AI generative options. + */ + @NestedConfigurationProperty + private WatsonxAiChatOptions options = WatsonxAiChatOptions.builder() + .model("google/flan-ul2") + .temperature(0.7) + .topP(1.0) + .topK(50) + .decodingMethod("greedy") + .maxNewTokens(20) + .minNewTokens(0) + .repetitionPenalty(1.0) + .stopSequences(List.of()) + .build(); + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public WatsonxAiChatOptions getOptions() { + return this.options; + } + + public void setOptions(WatsonxAiChatOptions options) { + this.options = options; + } + +} diff --git a/auto-configurations/models/spring-ai-watsonx-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAiConnectionProperties.java b/auto-configurations/models/spring-ai-watsonx-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAiConnectionProperties.java new file mode 100644 index 00000000000..5e4fecf2133 --- /dev/null +++ b/auto-configurations/models/spring-ai-watsonx-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAiConnectionProperties.java @@ -0,0 +1,93 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.watsonxai; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * WatsonX.ai connection autoconfiguration properties. + * + * @author Pablo Sanchidrian Herrera + * @author John Jario Moreno Rojas + * @since 1.0.0 + */ +@ConfigurationProperties(WatsonxAiConnectionProperties.CONFIG_PREFIX) +public class WatsonxAiConnectionProperties { + + public static final String CONFIG_PREFIX = "spring.ai.watsonx.ai"; + + private String baseUrl = "https://us-south.ml.cloud.ibm.com/"; + + private String streamEndpoint = "ml/v1/text/generation_stream?version=2023-05-29"; + + private String textEndpoint = "ml/v1/text/generation?version=2023-05-29"; + + private String embeddingEndpoint = "ml/v1/text/embeddings?version=2023-05-29"; + + private String projectId; + + private String IAMToken; + + public String getBaseUrl() { + return this.baseUrl; + } + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + + public String getStreamEndpoint() { + return this.streamEndpoint; + } + + public void setStreamEndpoint(String streamEndpoint) { + this.streamEndpoint = streamEndpoint; + } + + public String getTextEndpoint() { + return this.textEndpoint; + } + + public void setTextEndpoint(String textEndpoint) { + this.textEndpoint = textEndpoint; + } + + public String getEmbeddingEndpoint() { + return this.embeddingEndpoint; + } + + public void setEmbeddingEndpoint(String embeddingEndpoint) { + this.embeddingEndpoint = embeddingEndpoint; + } + + public String getProjectId() { + return this.projectId; + } + + public void setProjectId(String projectId) { + this.projectId = projectId; + } + + public String getIAMToken() { + return this.IAMToken; + } + + public void setIAMToken(String IAMToken) { + this.IAMToken = IAMToken; + } + +} diff --git a/auto-configurations/models/spring-ai-watsonx-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAiEmbeddingProperties.java b/auto-configurations/models/spring-ai-watsonx-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAiEmbeddingProperties.java new file mode 100644 index 00000000000..983291d21ed --- /dev/null +++ b/auto-configurations/models/spring-ai-watsonx-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAiEmbeddingProperties.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.autoconfigure.watsonxai; + +import org.springframework.ai.watsonx.WatsonxAiEmbeddingOptions; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * Watsonx.ai Embedding autoconfiguration properties. + * + * @author Pablo Sanchidrian Herrera + * @since 1.0.0 + */ +@ConfigurationProperties(WatsonxAiEmbeddingProperties.CONFIG_PREFIX) +public class WatsonxAiEmbeddingProperties { + + public static final String CONFIG_PREFIX = "spring.ai.watsonx.ai.embedding"; + + /** + * Enable Watsonx.ai embedding model. + */ + private boolean enabled = true; + + /** + * Client lever Watsonx.ai embedding options. Use this property to configure the + * model. The null values are ignored defaulting to the defaults. + */ + @NestedConfigurationProperty + private WatsonxAiEmbeddingOptions options = WatsonxAiEmbeddingOptions.create() + .withModel(WatsonxAiEmbeddingOptions.DEFAULT_MODEL); + + public String getModel() { + return this.options.getModel(); + } + + public void setModel(String model) { + this.options.setModel(model); + } + + public WatsonxAiEmbeddingOptions getOptions() { + return this.options; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + +} diff --git a/auto-configurations/models/spring-ai-watsonx-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/models/spring-ai-watsonx-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..58b25b9763a --- /dev/null +++ b/auto-configurations/models/spring-ai-watsonx-ai-spring-boot-autoconfigure/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.autoconfigure.watsonxai.WatsonxAiAutoConfiguration diff --git a/auto-configurations/models/spring-ai-watsonx-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAiAutoConfigurationTests.java b/auto-configurations/models/spring-ai-watsonx-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAiAutoConfigurationTests.java new file mode 100644 index 00000000000..1017332b342 --- /dev/null +++ b/auto-configurations/models/spring-ai-watsonx-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAiAutoConfigurationTests.java @@ -0,0 +1,56 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.watsonxai; + +import org.junit.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +public class WatsonxAiAutoConfigurationTests { + + @Test + public void propertiesTest() { + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.watsonx.ai.base-url=TEST_BASE_URL", + "spring.ai.watsonx.ai.stream-endpoint=ml/v1/text/generation_stream?version=2023-05-29", + "spring.ai.watsonx.ai.text-endpoint=ml/v1/text/generation?version=2023-05-29", + "spring.ai.watsonx.ai.embedding-endpoint=ml/v1/text/embeddings?version=2023-05-29", + "spring.ai.watsonx.ai.projectId=1", + "spring.ai.watsonx.ai.IAMToken=123456") + // @formatter:on + .withConfiguration( + AutoConfigurations.of(RestClientAutoConfiguration.class, WatsonxAiAutoConfiguration.class)) + .run(context -> { + var connectionProperties = context.getBean(WatsonxAiConnectionProperties.class); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + assertThat(connectionProperties.getStreamEndpoint()) + .isEqualTo("ml/v1/text/generation_stream?version=2023-05-29"); + assertThat(connectionProperties.getTextEndpoint()) + .isEqualTo("ml/v1/text/generation?version=2023-05-29"); + assertThat(connectionProperties.getEmbeddingEndpoint()) + .isEqualTo("ml/v1/text/embeddings?version=2023-05-29"); + assertThat(connectionProperties.getProjectId()).isEqualTo("1"); + assertThat(connectionProperties.getIAMToken()).isEqualTo("123456"); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/pom.xml b/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/pom.xml new file mode 100644 index 00000000000..3f137202e91 --- /dev/null +++ b/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/pom.xml @@ -0,0 +1,86 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../../pom.xml + + spring-ai-zhipuai-spring-boot-autoconfigure + jar + Spring AI ZhipuAI Auto Configuration + Spring AI ZhipuAI 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-zhipuai + ${project.parent.version} + true + + + + + + org.springframework.ai + spring-ai-chat-client-spring-boot-autoconfigure + ${project.parent.version} + true + + + + org.springframework.ai + spring-ai-retry-spring-boot-autoconfigure + ${project.parent.version} + true + + + + + org.springframework.boot + spring-boot-starter + true + + + + org.springframework.boot + spring-boot-configuration-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-zhipuai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiAutoConfiguration.java b/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiAutoConfiguration.java new file mode 100644 index 00000000000..4ff96a55525 --- /dev/null +++ b/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiAutoConfiguration.java @@ -0,0 +1,147 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.zhipuai; + +import java.util.List; + +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.chat.observation.ChatModelObservationConvention; +import org.springframework.ai.embedding.observation.EmbeddingModelObservationConvention; +import org.springframework.ai.model.function.DefaultFunctionCallbackResolver; +import org.springframework.ai.model.function.FunctionCallback; +import org.springframework.ai.model.function.FunctionCallbackResolver; +import org.springframework.ai.zhipuai.ZhiPuAiChatModel; +import org.springframework.ai.zhipuai.ZhiPuAiEmbeddingModel; +import org.springframework.ai.zhipuai.ZhiPuAiImageModel; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi; +import org.springframework.ai.zhipuai.api.ZhiPuAiImageApi; +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.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.web.client.ResponseErrorHandler; +import org.springframework.web.client.RestClient; + +/** + * {@link AutoConfiguration Auto-configuration} for ZhiPuAI. + * + * @author Geng Rong + */ +@AutoConfiguration(after = { RestClientAutoConfiguration.class, SpringAiRetryAutoConfiguration.class }) +@ConditionalOnClass(ZhiPuAiApi.class) +@EnableConfigurationProperties({ ZhiPuAiConnectionProperties.class, ZhiPuAiChatProperties.class, + ZhiPuAiEmbeddingProperties.class, ZhiPuAiImageProperties.class }) +public class ZhiPuAiAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = ZhiPuAiChatProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + public ZhiPuAiChatModel zhiPuAiChatModel(ZhiPuAiConnectionProperties commonProperties, + ZhiPuAiChatProperties chatProperties, ObjectProvider restClientBuilderProvider, + List toolFunctionCallbacks, FunctionCallbackResolver functionCallbackResolver, + RetryTemplate retryTemplate, ResponseErrorHandler responseErrorHandler, + ObjectProvider observationRegistry, + ObjectProvider observationConvention) { + + var zhiPuAiApi = zhiPuAiApi(chatProperties.getBaseUrl(), commonProperties.getBaseUrl(), + chatProperties.getApiKey(), commonProperties.getApiKey(), + restClientBuilderProvider.getIfAvailable(RestClient::builder), responseErrorHandler); + + var chatModel = new ZhiPuAiChatModel(zhiPuAiApi, chatProperties.getOptions(), functionCallbackResolver, + toolFunctionCallbacks, retryTemplate, observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)); + + observationConvention.ifAvailable(chatModel::setObservationConvention); + + return chatModel; + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = ZhiPuAiEmbeddingProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + public ZhiPuAiEmbeddingModel zhiPuAiEmbeddingModel(ZhiPuAiConnectionProperties commonProperties, + ZhiPuAiEmbeddingProperties embeddingProperties, RestClient.Builder restClientBuilder, + RetryTemplate retryTemplate, ResponseErrorHandler responseErrorHandler, + ObjectProvider observationRegistry, + ObjectProvider observationConvention) { + + var zhiPuAiApi = zhiPuAiApi(embeddingProperties.getBaseUrl(), commonProperties.getBaseUrl(), + embeddingProperties.getApiKey(), commonProperties.getApiKey(), restClientBuilder, responseErrorHandler); + + var embeddingModel = new ZhiPuAiEmbeddingModel(zhiPuAiApi, embeddingProperties.getMetadataMode(), + embeddingProperties.getOptions(), retryTemplate, + observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)); + + observationConvention.ifAvailable(embeddingModel::setObservationConvention); + + return embeddingModel; + } + + private ZhiPuAiApi zhiPuAiApi(String baseUrl, String commonBaseUrl, String apiKey, String commonApiKey, + RestClient.Builder restClientBuilder, ResponseErrorHandler responseErrorHandler) { + + String resolvedBaseUrl = StringUtils.hasText(baseUrl) ? baseUrl : commonBaseUrl; + Assert.hasText(resolvedBaseUrl, "ZhiPuAI base URL must be set"); + + String resolvedApiKey = StringUtils.hasText(apiKey) ? apiKey : commonApiKey; + Assert.hasText(resolvedApiKey, "ZhiPuAI API key must be set"); + + return new ZhiPuAiApi(resolvedBaseUrl, resolvedApiKey, restClientBuilder, responseErrorHandler); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = ZhiPuAiImageProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + public ZhiPuAiImageModel zhiPuAiImageModel(ZhiPuAiConnectionProperties commonProperties, + ZhiPuAiImageProperties imageProperties, RestClient.Builder restClientBuilder, RetryTemplate retryTemplate, + ResponseErrorHandler responseErrorHandler) { + + String apiKey = StringUtils.hasText(imageProperties.getApiKey()) ? imageProperties.getApiKey() + : commonProperties.getApiKey(); + + String baseUrl = StringUtils.hasText(imageProperties.getBaseUrl()) ? imageProperties.getBaseUrl() + : commonProperties.getBaseUrl(); + + Assert.hasText(apiKey, "ZhiPuAI API key must be set"); + Assert.hasText(baseUrl, "ZhiPuAI base URL must be set"); + + var zhiPuAiImageApi = new ZhiPuAiImageApi(baseUrl, apiKey, restClientBuilder, responseErrorHandler); + + return new ZhiPuAiImageModel(zhiPuAiImageApi, imageProperties.getOptions(), retryTemplate); + } + + @Bean + @ConditionalOnMissingBean + public FunctionCallbackResolver springAiFunctionManager(ApplicationContext context) { + DefaultFunctionCallbackResolver manager = new DefaultFunctionCallbackResolver(); + manager.setApplicationContext(context); + return manager; + } + +} diff --git a/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiChatProperties.java b/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiChatProperties.java new file mode 100644 index 00000000000..516105c2552 --- /dev/null +++ b/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiChatProperties.java @@ -0,0 +1,65 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.zhipuai; + +import org.springframework.ai.zhipuai.ZhiPuAiChatOptions; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * Configuration properties for ZhiPuAI chat model. + * + * @author Geng Rong + */ +@ConfigurationProperties(ZhiPuAiChatProperties.CONFIG_PREFIX) +public class ZhiPuAiChatProperties extends ZhiPuAiParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.zhipuai.chat"; + + public static final String DEFAULT_CHAT_MODEL = ZhiPuAiApi.ChatModel.GLM_4_Air.value; + + private static final Double DEFAULT_TEMPERATURE = 0.7; + + /** + * Enable ZhiPuAI chat model. + */ + private boolean enabled = true; + + @NestedConfigurationProperty + private ZhiPuAiChatOptions options = ZhiPuAiChatOptions.builder() + .model(DEFAULT_CHAT_MODEL) + .temperature(DEFAULT_TEMPERATURE) + .build(); + + public ZhiPuAiChatOptions getOptions() { + return this.options; + } + + public void setOptions(ZhiPuAiChatOptions options) { + this.options = options; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + +} diff --git a/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiConnectionProperties.java b/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiConnectionProperties.java new file mode 100644 index 00000000000..798afd43a97 --- /dev/null +++ b/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiConnectionProperties.java @@ -0,0 +1,32 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.zhipuai; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(ZhiPuAiConnectionProperties.CONFIG_PREFIX) +public class ZhiPuAiConnectionProperties extends ZhiPuAiParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.zhipuai"; + + public static final String DEFAULT_BASE_URL = "https://open.bigmodel.cn/api/paas"; + + public ZhiPuAiConnectionProperties() { + super.setBaseUrl(DEFAULT_BASE_URL); + } + +} diff --git a/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiEmbeddingProperties.java b/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiEmbeddingProperties.java new file mode 100644 index 00000000000..f069170517d --- /dev/null +++ b/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiEmbeddingProperties.java @@ -0,0 +1,71 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.zhipuai; + +import org.springframework.ai.document.MetadataMode; +import org.springframework.ai.zhipuai.ZhiPuAiEmbeddingOptions; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * Configuration properties for ZhiPuAI embedding model. + * + * @author Geng Rong + */ +@ConfigurationProperties(ZhiPuAiEmbeddingProperties.CONFIG_PREFIX) +public class ZhiPuAiEmbeddingProperties extends ZhiPuAiParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.zhipuai.embedding"; + + public static final String DEFAULT_EMBEDDING_MODEL = ZhiPuAiApi.EmbeddingModel.Embedding_2.value; + + /** + * Enable ZhiPuAI embedding model. + */ + private boolean enabled = true; + + private MetadataMode metadataMode = MetadataMode.EMBED; + + @NestedConfigurationProperty + private ZhiPuAiEmbeddingOptions options = ZhiPuAiEmbeddingOptions.builder().model(DEFAULT_EMBEDDING_MODEL).build(); + + public ZhiPuAiEmbeddingOptions getOptions() { + return this.options; + } + + public void setOptions(ZhiPuAiEmbeddingOptions options) { + this.options = options; + } + + public MetadataMode getMetadataMode() { + return this.metadataMode; + } + + public void setMetadataMode(MetadataMode metadataMode) { + this.metadataMode = metadataMode; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + +} diff --git a/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiImageProperties.java b/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiImageProperties.java new file mode 100644 index 00000000000..7266eeb13bb --- /dev/null +++ b/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiImageProperties.java @@ -0,0 +1,60 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.zhipuai; + +import org.springframework.ai.zhipuai.ZhiPuAiImageOptions; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * Configuration properties for ZhiPuAI chat model. + * + * @author Geng Rong + */ +@ConfigurationProperties(ZhiPuAiImageProperties.CONFIG_PREFIX) +public class ZhiPuAiImageProperties extends ZhiPuAiParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.zhipuai.image"; + + /** + * Enable ZhiPuAI image model. + */ + private boolean enabled = true; + + /** + * Options for ZhiPuAI Image API. + */ + @NestedConfigurationProperty + private ZhiPuAiImageOptions options = ZhiPuAiImageOptions.builder().build(); + + public ZhiPuAiImageOptions getOptions() { + return this.options; + } + + public void setOptions(ZhiPuAiImageOptions options) { + this.options = options; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + +} diff --git a/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiParentProperties.java b/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiParentProperties.java new file mode 100644 index 00000000000..c89102ec103 --- /dev/null +++ b/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiParentProperties.java @@ -0,0 +1,44 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.zhipuai; + +/** + * @author Geng Rong + */ +class ZhiPuAiParentProperties { + + 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-zhipuai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..741d559d4bd --- /dev/null +++ b/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/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.autoconfigure.zhipuai.ZhiPuAiAutoConfiguration diff --git a/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiAutoConfigurationIT.java b/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiAutoConfigurationIT.java new file mode 100644 index 00000000000..f60814cb4cf --- /dev/null +++ b/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiAutoConfigurationIT.java @@ -0,0 +1,111 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.zhipuai; + +import java.util.List; +import java.util.stream.Collectors; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import reactor.core.publisher.Flux; + +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.ai.image.ImagePrompt; +import org.springframework.ai.image.ImageResponse; +import org.springframework.ai.zhipuai.ZhiPuAiChatModel; +import org.springframework.ai.zhipuai.ZhiPuAiEmbeddingModel; +import org.springframework.ai.zhipuai.ZhiPuAiImageModel; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Geng Rong + */ +@EnabledIfEnvironmentVariable(named = "ZHIPU_AI_API_KEY", matches = ".*") +public class ZhiPuAiAutoConfigurationIT { + + private static final Log logger = LogFactory.getLog(ZhiPuAiAutoConfigurationIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.zhipuai.apiKey=" + System.getenv("ZHIPU_AI_API_KEY")) + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)); + + @Test + void generate() { + this.contextRunner.run(context -> { + ZhiPuAiChatModel chatModel = context.getBean(ZhiPuAiChatModel.class); + String response = chatModel.call("Hello"); + assertThat(response).isNotEmpty(); + logger.info("Response: " + response); + }); + } + + @Test + void generateStreaming() { + this.contextRunner.run(context -> { + ZhiPuAiChatModel chatModel = context.getBean(ZhiPuAiChatModel.class); + Flux responseFlux = chatModel.stream(new Prompt(new UserMessage("Hello"))); + String response = responseFlux.collectList() + .block() + .stream() + .map(chatResponse -> chatResponse.getResults().get(0).getOutput().getText()) + .collect(Collectors.joining()); + + assertThat(response).isNotEmpty(); + logger.info("Response: " + response); + }); + } + + @Test + void embedding() { + this.contextRunner.run(context -> { + ZhiPuAiEmbeddingModel embeddingModel = context.getBean(ZhiPuAiEmbeddingModel.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(1024); + }); + } + + @Test + void generateImage() { + this.contextRunner.withPropertyValues("spring.ai.zhipuai.image.options.size=1024x1024").run(context -> { + ZhiPuAiImageModel ImageModel = context.getBean(ZhiPuAiImageModel.class); + ImageResponse imageResponse = ImageModel.call(new ImagePrompt("forest")); + assertThat(imageResponse.getResults()).hasSize(1); + assertThat(imageResponse.getResult().getOutput().getUrl()).isNotEmpty(); + logger.info("Generated image: " + imageResponse.getResult().getOutput().getUrl()); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiPropertiesTests.java b/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiPropertiesTests.java new file mode 100644 index 00000000000..ac6e743e53f --- /dev/null +++ b/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiPropertiesTests.java @@ -0,0 +1,432 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.zhipuai; + +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; +import org.skyscreamer.jsonassert.JSONCompareMode; + +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.zhipuai.ZhiPuAiChatModel; +import org.springframework.ai.zhipuai.ZhiPuAiEmbeddingModel; +import org.springframework.ai.zhipuai.ZhiPuAiImageModel; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi; +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 ZhiPuAiConnectionProperties}, {@link ZhiPuAiChatProperties} and + * {@link ZhiPuAiEmbeddingProperties}. + * + * @author Geng Rong + */ +public class ZhiPuAiPropertiesTests { + + @Test + public void chatProperties() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.zhipuai.base-url=TEST_BASE_URL", + "spring.ai.zhipuai.api-key=abc123", + "spring.ai.zhipuai.chat.options.model=MODEL_XYZ", + "spring.ai.zhipuai.chat.options.temperature=0.55") + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(ZhiPuAiChatProperties.class); + var connectionProperties = context.getBean(ZhiPuAiConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(chatProperties.getApiKey()).isNull(); + assertThat(chatProperties.getBaseUrl()).isNull(); + + assertThat(chatProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55); + }); + } + + @Test + public void chatOverrideConnectionProperties() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.zhipuai.base-url=TEST_BASE_URL", + "spring.ai.zhipuai.api-key=abc123", + "spring.ai.zhipuai.chat.base-url=TEST_BASE_URL2", + "spring.ai.zhipuai.chat.api-key=456", + "spring.ai.zhipuai.chat.options.model=MODEL_XYZ", + "spring.ai.zhipuai.chat.options.temperature=0.55") + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(ZhiPuAiChatProperties.class); + var connectionProperties = context.getBean(ZhiPuAiConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(chatProperties.getApiKey()).isEqualTo("456"); + assertThat(chatProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL2"); + + assertThat(chatProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55); + }); + } + + @Test + public void embeddingProperties() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.zhipuai.base-url=TEST_BASE_URL", + "spring.ai.zhipuai.api-key=abc123", + "spring.ai.zhipuai.embedding.options.model=MODEL_XYZ") + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + var embeddingProperties = context.getBean(ZhiPuAiEmbeddingProperties.class); + var connectionProperties = context.getBean(ZhiPuAiConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(embeddingProperties.getApiKey()).isNull(); + assertThat(embeddingProperties.getBaseUrl()).isNull(); + + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + }); + } + + @Test + public void embeddingOverrideConnectionProperties() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.zhipuai.base-url=TEST_BASE_URL", + "spring.ai.zhipuai.api-key=abc123", + "spring.ai.zhipuai.embedding.base-url=TEST_BASE_URL2", + "spring.ai.zhipuai.embedding.api-key=456", + "spring.ai.zhipuai.embedding.options.model=MODEL_XYZ") + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + var embeddingProperties = context.getBean(ZhiPuAiEmbeddingProperties.class); + var connectionProperties = context.getBean(ZhiPuAiConnectionProperties.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 imageProperties() { + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.zhipuai.base-url=TEST_BASE_URL", + "spring.ai.zhipuai.api-key=abc123", + "spring.ai.zhipuai.image.options.model=MODEL_XYZ") + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + var imageProperties = context.getBean(ZhiPuAiImageProperties.class); + var connectionProperties = context.getBean(ZhiPuAiConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(imageProperties.getApiKey()).isNull(); + assertThat(imageProperties.getBaseUrl()).isNull(); + + assertThat(imageProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + }); + } + + @Test + public void imageOverrideConnectionProperties() { + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.zhipuai.base-url=TEST_BASE_URL", + "spring.ai.zhipuai.api-key=abc123", + "spring.ai.zhipuai.image.base-url=TEST_BASE_URL2", + "spring.ai.zhipuai.image.api-key=456", + "spring.ai.zhipuai.image.options.model=MODEL_XYZ") + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + var imageProperties = context.getBean(ZhiPuAiImageProperties.class); + var connectionProperties = context.getBean(ZhiPuAiConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(imageProperties.getApiKey()).isEqualTo("456"); + assertThat(imageProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL2"); + + assertThat(imageProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + }); + } + + @Test + public void chatOptionsTest() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.zhipuai.api-key=API_KEY", + "spring.ai.zhipuai.base-url=TEST_BASE_URL", + + "spring.ai.zhipuai.chat.options.model=MODEL_XYZ", + "spring.ai.zhipuai.chat.options.maxTokens=123", + "spring.ai.zhipuai.chat.options.stop=boza,koza", + "spring.ai.zhipuai.chat.options.temperature=0.55", + "spring.ai.zhipuai.chat.options.topP=0.56", + "spring.ai.zhipuai.chat.options.requestId=RequestId", + "spring.ai.zhipuai.chat.options.doSample=true", + + // "spring.ai.zhipuai.chat.options.toolChoice.functionName=toolChoiceFunctionName", + "spring.ai.zhipuai.chat.options.toolChoice=" + ModelOptionsUtils.toJsonString(ZhiPuAiApi.ChatCompletionRequest.ToolChoiceBuilder.function("toolChoiceFunctionName")), + + "spring.ai.zhipuai.chat.options.tools[0].function.name=myFunction1", + "spring.ai.zhipuai.chat.options.tools[0].function.description=function description", + "spring.ai.zhipuai.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"] + } + """, + "spring.ai.zhipuai.chat.options.user=userXYZ" + ) + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(ZhiPuAiChatProperties.class); + var connectionProperties = context.getBean(ZhiPuAiConnectionProperties.class); + var embeddingProperties = context.getBean(ZhiPuAiEmbeddingProperties.class); + + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY"); + + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("Embedding-2"); + + assertThat(chatProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(chatProperties.getOptions().getMaxTokens()).isEqualTo(123); + assertThat(chatProperties.getOptions().getStop()).contains("boza", "koza"); + assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55); + assertThat(chatProperties.getOptions().getTopP()).isEqualTo(0.56); + assertThat(chatProperties.getOptions().getRequestId()).isEqualTo("RequestId"); + assertThat(chatProperties.getOptions().getDoSample()).isEqualTo(Boolean.TRUE); + + JSONAssert.assertEquals("{\"type\":\"function\",\"function\":{\"name\":\"toolChoiceFunctionName\"}}", + chatProperties.getOptions().getToolChoice(), JSONCompareMode.LENIENT); + + assertThat(chatProperties.getOptions().getUser()).isEqualTo("userXYZ"); + + assertThat(chatProperties.getOptions().getTools()).hasSize(1); + var tool = chatProperties.getOptions().getTools().get(0); + assertThat(tool.getType()).isEqualTo(ZhiPuAiApi.FunctionTool.Type.FUNCTION); + var function = tool.getFunction(); + assertThat(function.getName()).isEqualTo("myFunction1"); + assertThat(function.getDescription()).isEqualTo("function description"); + assertThat(function.getParameters()).isNotEmpty(); + }); + } + + @Test + public void embeddingOptionsTest() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.zhipuai.api-key=API_KEY", + "spring.ai.zhipuai.base-url=TEST_BASE_URL", + + "spring.ai.zhipuai.embedding.options.model=MODEL_XYZ", + "spring.ai.zhipuai.embedding.options.encodingFormat=MyEncodingFormat", + "spring.ai.zhipuai.embedding.options.user=userXYZ" + ) + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + var connectionProperties = context.getBean(ZhiPuAiConnectionProperties.class); + var embeddingProperties = context.getBean(ZhiPuAiEmbeddingProperties.class); + + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY"); + + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + }); + } + + @Test + public void imageOptionsTest() { + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.zhipuai.api-key=API_KEY", + "spring.ai.zhipuai.base-url=TEST_BASE_URL", + "spring.ai.zhipuai.image.options.model=MODEL_XYZ", + "spring.ai.zhipuai.image.options.user=userXYZ" + ) + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + var imageProperties = context.getBean(ZhiPuAiImageProperties.class); + var connectionProperties = context.getBean(ZhiPuAiConnectionProperties.class); + + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY"); + assertThat(imageProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(imageProperties.getOptions().getUser()).isEqualTo("userXYZ"); + }); + } + + @Test + void embeddingActivation() { + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.zhipuai.api-key=API_KEY", "spring.ai.zhipuai.base-url=TEST_BASE_URL", + "spring.ai.zhipuai.embedding.enabled=false") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(ZhiPuAiEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(ZhiPuAiEmbeddingModel.class)).isEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.zhipuai.api-key=API_KEY", "spring.ai.zhipuai.base-url=TEST_BASE_URL") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(ZhiPuAiEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(ZhiPuAiEmbeddingModel.class)).isNotEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.zhipuai.api-key=API_KEY", "spring.ai.zhipuai.base-url=TEST_BASE_URL", + "spring.ai.zhipuai.embedding.enabled=true") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(ZhiPuAiEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(ZhiPuAiEmbeddingModel.class)).isNotEmpty(); + }); + } + + @Test + void chatActivation() { + new ApplicationContextRunner() + .withPropertyValues("spring.ai.zhipuai.api-key=API_KEY", "spring.ai.zhipuai.base-url=TEST_BASE_URL", + "spring.ai.zhipuai.chat.enabled=false") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(ZhiPuAiChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(ZhiPuAiChatModel.class)).isEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.zhipuai.api-key=API_KEY", "spring.ai.zhipuai.base-url=TEST_BASE_URL") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(ZhiPuAiChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(ZhiPuAiChatModel.class)).isNotEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.zhipuai.api-key=API_KEY", "spring.ai.zhipuai.base-url=TEST_BASE_URL", + "spring.ai.zhipuai.chat.enabled=true") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(ZhiPuAiChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(ZhiPuAiChatModel.class)).isNotEmpty(); + }); + + } + + @Test + void imageActivation() { + new ApplicationContextRunner() + .withPropertyValues("spring.ai.zhipuai.api-key=API_KEY", "spring.ai.zhipuai.base-url=TEST_BASE_URL", + "spring.ai.zhipuai.image.enabled=false") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(ZhiPuAiImageProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(ZhiPuAiImageModel.class)).isEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.zhipuai.api-key=API_KEY", "spring.ai.zhipuai.base-url=TEST_BASE_URL") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(ZhiPuAiImageProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(ZhiPuAiImageModel.class)).isNotEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.zhipuai.api-key=API_KEY", "spring.ai.zhipuai.base-url=TEST_BASE_URL", + "spring.ai.zhipuai.image.enabled=true") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(ZhiPuAiImageProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(ZhiPuAiImageModel.class)).isNotEmpty(); + }); + + } + +} diff --git a/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/tool/FunctionCallbackInPromptIT.java b/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/tool/FunctionCallbackInPromptIT.java new file mode 100644 index 00000000000..556e7df8c19 --- /dev/null +++ b/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/tool/FunctionCallbackInPromptIT.java @@ -0,0 +1,120 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.zhipuai.tool; + +import java.util.List; +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.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.autoconfigure.zhipuai.ZhiPuAiAutoConfiguration; +import org.springframework.ai.chat.messages.AssistantMessage; +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.Prompt; +import org.springframework.ai.model.function.FunctionCallback; +import org.springframework.ai.zhipuai.ZhiPuAiChatModel; +import org.springframework.ai.zhipuai.ZhiPuAiChatOptions; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Geng Rong + */ +@EnabledIfEnvironmentVariable(named = "ZHIPU_AI_API_KEY", matches = ".*") +public class FunctionCallbackInPromptIT { + + private final Logger logger = LoggerFactory.getLogger(FunctionCallbackInPromptIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.zhipuai.apiKey=" + System.getenv("ZHIPU_AI_API_KEY")) + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)); + + @Test + void functionCallTest() { + this.contextRunner.withPropertyValues("spring.ai.zhipuai.chat.options.model=glm-4").run(context -> { + + ZhiPuAiChatModel chatModel = context.getBean(ZhiPuAiChatModel.class); + + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius."); + + var promptOptions = ZhiPuAiChatOptions.builder() + .functionCallbacks(List.of(FunctionCallback.builder() + .function("CurrentWeatherService", new MockWeatherService()) + .description("Get the weather in location") + .inputType(MockWeatherService.Request.class) + // .responseConverter(response -> "" + response.temp() + + // response.unit()) + .build())) + .build(); + + ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), promptOptions)); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + }); + } + + @Test + void streamingFunctionCallTest() { + + this.contextRunner.withPropertyValues("spring.ai.zhipuai.chat.options.model=glm-4").run(context -> { + + ZhiPuAiChatModel chatModel = context.getBean(ZhiPuAiChatModel.class); + + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius."); + + var promptOptions = ZhiPuAiChatOptions.builder() + .functionCallbacks(List.of(FunctionCallback.builder() + .function("CurrentWeatherService", new MockWeatherService()) + .description("Get the weather in location") + .inputType(MockWeatherService.Request.class) + .build())) + .build(); + + Flux response = chatModel.stream(new Prompt(List.of(userMessage), 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("30.0", "30"); + assertThat(content).containsAnyOf("10.0", "10"); + assertThat(content).containsAnyOf("15.0", "15"); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/tool/FunctionCallbackWithPlainFunctionBeanIT.java b/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/tool/FunctionCallbackWithPlainFunctionBeanIT.java new file mode 100644 index 00000000000..714ea619c44 --- /dev/null +++ b/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/tool/FunctionCallbackWithPlainFunctionBeanIT.java @@ -0,0 +1,176 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.zhipuai.tool; + +import java.util.List; +import java.util.function.Function; +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.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.autoconfigure.zhipuai.ZhiPuAiAutoConfiguration; +import org.springframework.ai.chat.messages.AssistantMessage; +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.Prompt; +import org.springframework.ai.model.function.FunctionCallingOptions; +import org.springframework.ai.zhipuai.ZhiPuAiChatModel; +import org.springframework.ai.zhipuai.ZhiPuAiChatOptions; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Description; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Geng Rong + */ +@EnabledIfEnvironmentVariable(named = "ZHIPU_AI_API_KEY", matches = ".*") +class FunctionCallbackWithPlainFunctionBeanIT { + + private final Logger logger = LoggerFactory.getLogger(FunctionCallbackWithPlainFunctionBeanIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.zhipuai.apiKey=" + System.getenv("ZHIPU_AI_API_KEY")) + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .withUserConfiguration(Config.class); + + @Test + void functionCallTest() { + this.contextRunner.withPropertyValues("spring.ai.zhipuai.chat.options.model=glm-4").run(context -> { + + ZhiPuAiChatModel chatModel = context.getBean(ZhiPuAiChatModel.class); + + // Test weatherFunction + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius."); + + ChatResponse response = chatModel.call( + new Prompt(List.of(userMessage), ZhiPuAiChatOptions.builder().function("weatherFunction").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + + // Test weatherFunctionTwo + response = chatModel.call(new Prompt(List.of(userMessage), + ZhiPuAiChatOptions.builder().function("weatherFunctionTwo").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + + }); + } + + @Test + void functionCallWithPortableFunctionCallingOptions() { + this.contextRunner.withPropertyValues("spring.ai.zhipuai.chat.options.model=glm-4").run(context -> { + + ZhiPuAiChatModel chatModel = context.getBean(ZhiPuAiChatModel.class); + + // Test weatherFunction + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius."); + + FunctionCallingOptions functionOptions = FunctionCallingOptions.builder() + .function("weatherFunction") + .build(); + + ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), functionOptions)); + + logger.info("Response: {}", response); + }); + } + + @Test + void streamFunctionCallTest() { + this.contextRunner.withPropertyValues("spring.ai.zhipuai.chat.options.model=glm-4").run(context -> { + + ZhiPuAiChatModel chatModel = context.getBean(ZhiPuAiChatModel.class); + + // Test weatherFunction + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius."); + + Flux response = chatModel.stream( + new Prompt(List.of(userMessage), ZhiPuAiChatOptions.builder().function("weatherFunction").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("30.0", "30"); + assertThat(content).containsAnyOf("10.0", "10"); + assertThat(content).containsAnyOf("15.0", "15"); + + // Test weatherFunctionTwo + response = chatModel.stream(new Prompt(List.of(userMessage), + ZhiPuAiChatOptions.builder().function("weatherFunctionTwo").build())); + + 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("30.0", "30"); + assertThat(content).containsAnyOf("10.0", "10"); + assertThat(content).containsAnyOf("15.0", "15"); + }); + } + + @Configuration + static class Config { + + @Bean + @Description("Get the weather in location") + public Function weatherFunction() { + return new MockWeatherService(); + } + + // Relies on the Request's JsonClassDescription annotation to provide the + // function description. + @Bean + public Function weatherFunctionTwo() { + MockWeatherService weatherService = new MockWeatherService(); + return (weatherService::apply); + } + + } + +} diff --git a/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/tool/MockWeatherService.java b/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/tool/MockWeatherService.java new file mode 100644 index 00000000000..43b4af8a57e --- /dev/null +++ b/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/tool/MockWeatherService.java @@ -0,0 +1,97 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.zhipuai.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; + +/** + * Mock 3rd party weather service. + * + * @author Geng Rong + */ +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 = "lat") @JsonPropertyDescription("The city latitude") double lat, + @JsonProperty(required = true, value = "lon") @JsonPropertyDescription("The city longitude") double lon, + @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/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/tool/ZhipuAiFunctionCallbackIT.java b/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/tool/ZhipuAiFunctionCallbackIT.java new file mode 100644 index 00000000000..6b258e98d1a --- /dev/null +++ b/auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/tool/ZhipuAiFunctionCallbackIT.java @@ -0,0 +1,124 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.zhipuai.tool; + +import java.util.List; +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.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.autoconfigure.zhipuai.ZhiPuAiAutoConfiguration; +import org.springframework.ai.chat.messages.AssistantMessage; +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.Prompt; +import org.springframework.ai.model.function.FunctionCallback; +import org.springframework.ai.zhipuai.ZhiPuAiChatModel; +import org.springframework.ai.zhipuai.ZhiPuAiChatOptions; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Geng Rong + */ +@EnabledIfEnvironmentVariable(named = "ZHIPU_AI_API_KEY", matches = ".*") +public class ZhipuAiFunctionCallbackIT { + + private final Logger logger = LoggerFactory.getLogger(ZhipuAiFunctionCallbackIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.zhipuai.apiKey=" + System.getenv("ZHIPU_AI_API_KEY")) + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .withUserConfiguration(Config.class); + + @Test + void functionCallTest() { + this.contextRunner.withPropertyValues("spring.ai.zhipuai.chat.options.model=glm-4").run(context -> { + + ZhiPuAiChatModel chatModel = context.getBean(ZhiPuAiChatModel.class); + + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius."); + + ChatResponse response = chatModel + .call(new Prompt(List.of(userMessage), ZhiPuAiChatOptions.builder().function("WeatherInfo").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + + }); + } + + @Test + void streamFunctionCallTest() { + this.contextRunner.withPropertyValues("spring.ai.zhipuai.chat.options.model=glm-4").run(context -> { + + ZhiPuAiChatModel chatModel = context.getBean(ZhiPuAiChatModel.class); + + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius."); + + Flux response = chatModel + .stream(new Prompt(List.of(userMessage), ZhiPuAiChatOptions.builder().function("WeatherInfo").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("30.0", "30"); + assertThat(content).containsAnyOf("10.0", "10"); + assertThat(content).containsAnyOf("15.0", "15"); + + }); + } + + @Configuration + static class Config { + + @Bean + public FunctionCallback weatherFunctionInfo() { + + return FunctionCallback.builder() + .function("WeatherInfo", new MockWeatherService()) + .description("Get the weather in location") + .inputType(MockWeatherService.Request.class) + // .responseConverter(response -> "" + response.temp() + response.unit()) + .build(); + } + + } + +} diff --git a/auto-configurations/observation/chat/spring-ai-chat-observation-spring-boot-autoconfigure/pom.xml b/auto-configurations/observation/chat/spring-ai-chat-observation-spring-boot-autoconfigure/pom.xml new file mode 100644 index 00000000000..8bd46177cf0 --- /dev/null +++ b/auto-configurations/observation/chat/spring-ai-chat-observation-spring-boot-autoconfigure/pom.xml @@ -0,0 +1,72 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../../../pom.xml + + spring-ai-chat-observation-spring-boot-autoconfigure + jar + Spring AI Chat Observation Auto Configuration + Spring AI Chat Observation 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-core + ${parent.version} + + + + io.micrometer + micrometer-tracing-bridge-otel + true + + + + + org.springframework.boot + spring-boot-starter + + + + org.springframework.boot + spring-boot-configuration-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/observation/chat/spring-ai-chat-observation-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationAutoConfiguration.java b/auto-configurations/observation/chat/spring-ai-chat-observation-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationAutoConfiguration.java new file mode 100644 index 00000000000..f439263fdad --- /dev/null +++ b/auto-configurations/observation/chat/spring-ai-chat-observation-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationAutoConfiguration.java @@ -0,0 +1,156 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.chat.observation; + +import java.util.List; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.tracing.Tracer; +import io.micrometer.tracing.otel.bridge.OtelTracer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.chat.client.advisor.observation.AdvisorObservationContext; +import org.springframework.ai.chat.client.observation.ChatClientObservationContext; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.observation.ChatModelCompletionObservationFilter; +import org.springframework.ai.chat.observation.ChatModelCompletionObservationHandler; +import org.springframework.ai.chat.observation.ChatModelMeterObservationHandler; +import org.springframework.ai.chat.observation.ChatModelObservationContext; +import org.springframework.ai.chat.observation.ChatModelPromptContentObservationFilter; +import org.springframework.ai.chat.observation.ChatModelPromptContentObservationHandler; +import org.springframework.ai.embedding.observation.EmbeddingModelObservationContext; +import org.springframework.ai.image.observation.ImageModelObservationContext; +import org.springframework.ai.model.observation.ErrorLoggingObservationHandler; +import org.springframework.ai.vectorstore.observation.VectorStoreObservationContext; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Auto-configuration for Spring AI chat model observations. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +@AutoConfiguration( + afterName = { "org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration" }) +@ConditionalOnClass(ChatModel.class) +@EnableConfigurationProperties({ ChatObservationProperties.class }) +public class ChatObservationAutoConfiguration { + + private static final Logger logger = LoggerFactory.getLogger(ChatObservationAutoConfiguration.class); + + private static void logPromptContentWarning() { + logger.warn( + "You have enabled the inclusion of the prompt content in the observations, with the risk of exposing sensitive or private information. Please, be careful!"); + } + + private static void logCompletionWarning() { + logger.warn( + "You have enabled the inclusion of the completion content in the observations, with the risk of exposing sensitive or private information. Please, be careful!"); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnBean(MeterRegistry.class) + ChatModelMeterObservationHandler chatModelMeterObservationHandler(ObjectProvider meterRegistry) { + return new ChatModelMeterObservationHandler(meterRegistry.getObject()); + } + + /** + * The chat content is typically too big to be included in an observation as span + * attributes. That's why the preferred way to store it is as span events, which are + * supported by OpenTelemetry but not yet surfaced through the Micrometer APIs. This + * primary/fallback configuration is a temporary solution until + * https://github.com/micrometer-metrics/micrometer/issues/5238 is delivered. + */ + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(OtelTracer.class) + @ConditionalOnBean(OtelTracer.class) + static class PrimaryChatContentObservationConfiguration { + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = ChatObservationProperties.CONFIG_PREFIX, name = "include-prompt", + havingValue = "true") + ChatModelPromptContentObservationHandler chatModelPromptContentObservationHandler() { + logPromptContentWarning(); + return new ChatModelPromptContentObservationHandler(); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = ChatObservationProperties.CONFIG_PREFIX, name = "include-completion", + havingValue = "true") + ChatModelCompletionObservationHandler chatModelCompletionObservationHandler() { + logCompletionWarning(); + return new ChatModelCompletionObservationHandler(); + } + + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnMissingClass("io.micrometer.tracing.otel.bridge.OtelTracer") + static class FallbackChatContentObservationConfiguration { + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = ChatObservationProperties.CONFIG_PREFIX, name = "include-prompt", + havingValue = "true") + ChatModelPromptContentObservationFilter chatModelPromptObservationFilter() { + logPromptContentWarning(); + return new ChatModelPromptContentObservationFilter(); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = ChatObservationProperties.CONFIG_PREFIX, name = "include-completion", + havingValue = "true") + ChatModelCompletionObservationFilter chatModelCompletionObservationFilter() { + logCompletionWarning(); + return new ChatModelCompletionObservationFilter(); + } + + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(Tracer.class) + @ConditionalOnBean(Tracer.class) + static class TracingChatContentObservationConfiguration { + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = ChatObservationProperties.CONFIG_PREFIX, name = "include-error-logging", + havingValue = "true") + public ErrorLoggingObservationHandler errorLoggingObservationHandler(Tracer tracer) { + return new ErrorLoggingObservationHandler(tracer, + List.of(EmbeddingModelObservationContext.class, ImageModelObservationContext.class, + ChatModelObservationContext.class, ChatClientObservationContext.class, + AdvisorObservationContext.class, VectorStoreObservationContext.class)); + } + + } + +} diff --git a/auto-configurations/observation/chat/spring-ai-chat-observation-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationProperties.java b/auto-configurations/observation/chat/spring-ai-chat-observation-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationProperties.java new file mode 100644 index 00000000000..cd353ac0b97 --- /dev/null +++ b/auto-configurations/observation/chat/spring-ai-chat-observation-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationProperties.java @@ -0,0 +1,71 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.chat.observation; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration properties for chat model observations. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +@ConfigurationProperties(ChatObservationProperties.CONFIG_PREFIX) +public class ChatObservationProperties { + + public static final String CONFIG_PREFIX = "spring.ai.chat.observations"; + + /** + * Whether to include the completion content in the observations. + */ + private boolean includeCompletion = false; + + /** + * Whether to include the prompt content in the observations. + */ + private boolean includePrompt = false; + + /** + * Whether to include error logging in the observations. + */ + private boolean includeErrorLogging = false; + + public boolean isIncludeCompletion() { + return this.includeCompletion; + } + + public void setIncludeCompletion(boolean includeCompletion) { + this.includeCompletion = includeCompletion; + } + + public boolean isIncludePrompt() { + return this.includePrompt; + } + + public void setIncludePrompt(boolean includePrompt) { + this.includePrompt = includePrompt; + } + + public boolean isIncludeErrorLogging() { + return this.includeErrorLogging; + } + + public void setIncludeErrorLogging(boolean includeErrorLogging) { + this.includeErrorLogging = includeErrorLogging; + } + +} diff --git a/auto-configurations/observation/chat/spring-ai-chat-observation-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/observation/package-info.java b/auto-configurations/observation/chat/spring-ai-chat-observation-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/observation/package-info.java new file mode 100644 index 00000000000..6032da63a0a --- /dev/null +++ b/auto-configurations/observation/chat/spring-ai-chat-observation-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/observation/package-info.java @@ -0,0 +1,25 @@ +/* + * 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. + */ + +/** + * Auto-configuration for chat observation. + */ +@NonNullApi +@NonNullFields +package org.springframework.ai.autoconfigure.chat.observation; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/auto-configurations/observation/chat/spring-ai-chat-observation-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/observation/chat/spring-ai-chat-observation-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..9493b75876d --- /dev/null +++ b/auto-configurations/observation/chat/spring-ai-chat-observation-spring-boot-autoconfigure/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.autoconfigure.chat.observation.ChatObservationAutoConfiguration diff --git a/auto-configurations/observation/chat/spring-ai-chat-observation-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationAutoConfigurationTests.java b/auto-configurations/observation/chat/spring-ai-chat-observation-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationAutoConfigurationTests.java new file mode 100644 index 00000000000..d1601098168 --- /dev/null +++ b/auto-configurations/observation/chat/spring-ai-chat-observation-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationAutoConfigurationTests.java @@ -0,0 +1,108 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.chat.observation; + +import io.micrometer.core.instrument.composite.CompositeMeterRegistry; +import io.micrometer.tracing.otel.bridge.OtelCurrentTraceContext; +import io.micrometer.tracing.otel.bridge.OtelTracer; +import io.opentelemetry.api.OpenTelemetry; +import org.junit.jupiter.api.Test; + +import org.springframework.ai.chat.observation.ChatModelCompletionObservationFilter; +import org.springframework.ai.chat.observation.ChatModelCompletionObservationHandler; +import org.springframework.ai.chat.observation.ChatModelMeterObservationHandler; +import org.springframework.ai.chat.observation.ChatModelPromptContentObservationFilter; +import org.springframework.ai.chat.observation.ChatModelPromptContentObservationHandler; +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 {@link ChatObservationAutoConfiguration}. + * + * @author Thomas Vitale + */ +class ChatObservationAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(ChatObservationAutoConfiguration.class)); + + @Test + void meterObservationHandlerEnabled() { + this.contextRunner.withBean(CompositeMeterRegistry.class) + .run(context -> assertThat(context).hasSingleBean(ChatModelMeterObservationHandler.class)); + } + + @Test + void meterObservationHandlerDisabled() { + this.contextRunner.run(context -> assertThat(context).doesNotHaveBean(ChatModelMeterObservationHandler.class)); + } + + @Test + void promptFilterDefault() { + this.contextRunner + .run(context -> assertThat(context).doesNotHaveBean(ChatModelPromptContentObservationFilter.class)); + } + + @Test + void promptHandlerDefault() { + this.contextRunner + .run(context -> assertThat(context).doesNotHaveBean(ChatModelPromptContentObservationHandler.class)); + } + + @Test + void promptHandlerEnabled() { + this.contextRunner + .withBean(OtelTracer.class, OpenTelemetry.noop().getTracer("test"), new OtelCurrentTraceContext(), null) + .withPropertyValues("spring.ai.chat.observations.include-prompt=true") + .run(context -> assertThat(context).hasSingleBean(ChatModelPromptContentObservationHandler.class)); + } + + @Test + void promptHandlerDisabled() { + this.contextRunner.withPropertyValues("spring.ai.chat.observations.include-prompt=true") + .run(context -> assertThat(context).doesNotHaveBean(ChatModelPromptContentObservationHandler.class)); + } + + @Test + void completionFilterDefault() { + this.contextRunner + .run(context -> assertThat(context).doesNotHaveBean(ChatModelCompletionObservationFilter.class)); + } + + @Test + void completionHandlerDefault() { + this.contextRunner + .run(context -> assertThat(context).doesNotHaveBean(ChatModelCompletionObservationHandler.class)); + } + + @Test + void completionHandlerEnabled() { + this.contextRunner + .withBean(OtelTracer.class, OpenTelemetry.noop().getTracer("test"), new OtelCurrentTraceContext(), null) + .withPropertyValues("spring.ai.chat.observations.include-completion=true") + .run(context -> assertThat(context).hasSingleBean(ChatModelCompletionObservationHandler.class)); + } + + @Test + void completionHandlerDisabled() { + this.contextRunner.withPropertyValues("spring.ai.chat.observations.include-completion=true") + .run(context -> assertThat(context).doesNotHaveBean(ChatModelCompletionObservationHandler.class)); + } + +} diff --git a/auto-configurations/observation/embedding/spring-ai-embedding-observation-spring-boot-autoconfigure/pom.xml b/auto-configurations/observation/embedding/spring-ai-embedding-observation-spring-boot-autoconfigure/pom.xml new file mode 100644 index 00000000000..d252bb6e7cb --- /dev/null +++ b/auto-configurations/observation/embedding/spring-ai-embedding-observation-spring-boot-autoconfigure/pom.xml @@ -0,0 +1,66 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../../../pom.xml + + spring-ai-embedding-observation-spring-boot-autoconfigure + jar + Spring AI Embedding Observation Auto Configuration + Spring AI Embedding Observation 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-core + ${parent.version} + + + + + org.springframework.boot + spring-boot-starter + + + + org.springframework.boot + spring-boot-configuration-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/observation/embedding/spring-ai-embedding-observation-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/embedding/observation/EmbeddingObservationAutoConfiguration.java b/auto-configurations/observation/embedding/spring-ai-embedding-observation-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/embedding/observation/EmbeddingObservationAutoConfiguration.java new file mode 100644 index 00000000000..ba1d61886f2 --- /dev/null +++ b/auto-configurations/observation/embedding/spring-ai-embedding-observation-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/embedding/observation/EmbeddingObservationAutoConfiguration.java @@ -0,0 +1,49 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.embedding.observation; + +import io.micrometer.core.instrument.MeterRegistry; + +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.embedding.observation.EmbeddingModelMeterObservationHandler; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; + +/** + * Auto-configuration for Spring AI embedding model observations. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +@AutoConfiguration( + afterName = "org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration") +@ConditionalOnClass(EmbeddingModel.class) +public class EmbeddingObservationAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + @ConditionalOnBean(MeterRegistry.class) + EmbeddingModelMeterObservationHandler embeddingModelMeterObservationHandler( + ObjectProvider meterRegistry) { + return new EmbeddingModelMeterObservationHandler(meterRegistry.getObject()); + } + +} diff --git a/auto-configurations/observation/embedding/spring-ai-embedding-observation-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/embedding/observation/package-info.java b/auto-configurations/observation/embedding/spring-ai-embedding-observation-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/embedding/observation/package-info.java new file mode 100644 index 00000000000..2275a771ee8 --- /dev/null +++ b/auto-configurations/observation/embedding/spring-ai-embedding-observation-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/embedding/observation/package-info.java @@ -0,0 +1,25 @@ +/* + * 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. + */ + +/** + * Auto-configuration for embedding observation. + */ +@NonNullApi +@NonNullFields +package org.springframework.ai.autoconfigure.embedding.observation; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/auto-configurations/observation/embedding/spring-ai-embedding-observation-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/observation/embedding/spring-ai-embedding-observation-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..162692d8ab9 --- /dev/null +++ b/auto-configurations/observation/embedding/spring-ai-embedding-observation-spring-boot-autoconfigure/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.autoconfigure.embedding.observation.EmbeddingObservationAutoConfiguration diff --git a/auto-configurations/observation/embedding/spring-ai-embedding-observation-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/embedding/observation/EmbeddingObservationAutoConfigurationTests.java b/auto-configurations/observation/embedding/spring-ai-embedding-observation-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/embedding/observation/EmbeddingObservationAutoConfigurationTests.java new file mode 100644 index 00000000000..42037c9e064 --- /dev/null +++ b/auto-configurations/observation/embedding/spring-ai-embedding-observation-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/embedding/observation/EmbeddingObservationAutoConfigurationTests.java @@ -0,0 +1,50 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.embedding.observation; + +import io.micrometer.core.instrument.composite.CompositeMeterRegistry; +import org.junit.jupiter.api.Test; + +import org.springframework.ai.embedding.observation.EmbeddingModelMeterObservationHandler; +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 {@link EmbeddingObservationAutoConfiguration}. + * + * @author Thomas Vitale + */ +class EmbeddingObservationAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(EmbeddingObservationAutoConfiguration.class)); + + @Test + void meterObservationHandlerEnabled() { + this.contextRunner.withBean(CompositeMeterRegistry.class) + .run(context -> assertThat(context).hasSingleBean(EmbeddingModelMeterObservationHandler.class)); + } + + @Test + void meterObservationHandlerDisabled() { + this.contextRunner + .run(context -> assertThat(context).doesNotHaveBean(EmbeddingModelMeterObservationHandler.class)); + } + +} diff --git a/auto-configurations/observation/image/spring-ai-image-observation-spring-boot-autoconfigure/pom.xml b/auto-configurations/observation/image/spring-ai-image-observation-spring-boot-autoconfigure/pom.xml new file mode 100644 index 00000000000..82a12f1002e --- /dev/null +++ b/auto-configurations/observation/image/spring-ai-image-observation-spring-boot-autoconfigure/pom.xml @@ -0,0 +1,66 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../../../pom.xml + + spring-ai-image-observation-spring-boot-autoconfigure + jar + Spring AI Image Observation Auto Configuration + Spring AI Image Observation 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-core + ${parent.version} + + + + + org.springframework.boot + spring-boot-starter + + + + org.springframework.boot + spring-boot-configuration-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/observation/image/spring-ai-image-observation-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/image/observation/ImageObservationAutoConfiguration.java b/auto-configurations/observation/image/spring-ai-image-observation-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/image/observation/ImageObservationAutoConfiguration.java new file mode 100644 index 00000000000..a89a42102a3 --- /dev/null +++ b/auto-configurations/observation/image/spring-ai-image-observation-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/image/observation/ImageObservationAutoConfiguration.java @@ -0,0 +1,55 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.image.observation; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.image.ImageModel; +import org.springframework.ai.image.observation.ImageModelPromptContentObservationFilter; +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.context.annotation.Bean; + +/** + * Auto-configuration for Spring AI image model observations. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +@AutoConfiguration( + afterName = "org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration.class") +@ConditionalOnClass(ImageModel.class) +@EnableConfigurationProperties({ ImageObservationProperties.class }) +public class ImageObservationAutoConfiguration { + + private static final Logger logger = LoggerFactory.getLogger(ImageObservationAutoConfiguration.class); + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = ImageObservationProperties.CONFIG_PREFIX, name = "include-prompt", + havingValue = "true") + ImageModelPromptContentObservationFilter imageModelPromptObservationFilter() { + logger.warn( + "You have enabled the inclusion of the image prompt content in the observations, with the risk of exposing sensitive or private information. Please, be careful!"); + return new ImageModelPromptContentObservationFilter(); + } + +} diff --git a/auto-configurations/observation/image/spring-ai-image-observation-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/image/observation/ImageObservationProperties.java b/auto-configurations/observation/image/spring-ai-image-observation-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/image/observation/ImageObservationProperties.java new file mode 100644 index 00000000000..3e454ee8d20 --- /dev/null +++ b/auto-configurations/observation/image/spring-ai-image-observation-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/image/observation/ImageObservationProperties.java @@ -0,0 +1,45 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.image.observation; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration properties for image model observations. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +@ConfigurationProperties(ImageObservationProperties.CONFIG_PREFIX) +public class ImageObservationProperties { + + public static final String CONFIG_PREFIX = "spring.ai.image.observations"; + + /** + * Whether to include the prompt content in the observations. + */ + private boolean includePrompt = false; + + public boolean isIncludePrompt() { + return this.includePrompt; + } + + public void setIncludePrompt(boolean includePrompt) { + this.includePrompt = includePrompt; + } + +} diff --git a/auto-configurations/observation/image/spring-ai-image-observation-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/image/observation/package-info.java b/auto-configurations/observation/image/spring-ai-image-observation-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/image/observation/package-info.java new file mode 100644 index 00000000000..559f61df956 --- /dev/null +++ b/auto-configurations/observation/image/spring-ai-image-observation-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/image/observation/package-info.java @@ -0,0 +1,25 @@ +/* + * 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. + */ + +/** + * Auto-configuration for image observation. + */ +@NonNullApi +@NonNullFields +package org.springframework.ai.autoconfigure.image.observation; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/auto-configurations/observation/image/spring-ai-image-observation-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/observation/image/spring-ai-image-observation-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..78af4bcffe8 --- /dev/null +++ b/auto-configurations/observation/image/spring-ai-image-observation-spring-boot-autoconfigure/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.autoconfigure.image.observation.ImageObservationAutoConfiguration diff --git a/auto-configurations/observation/image/spring-ai-image-observation-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/image/observation/ImageObservationAutoConfigurationTests.java b/auto-configurations/observation/image/spring-ai-image-observation-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/image/observation/ImageObservationAutoConfigurationTests.java new file mode 100644 index 00000000000..deb0a22ade3 --- /dev/null +++ b/auto-configurations/observation/image/spring-ai-image-observation-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/image/observation/ImageObservationAutoConfigurationTests.java @@ -0,0 +1,49 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.image.observation; + +import org.junit.jupiter.api.Test; + +import org.springframework.ai.image.observation.ImageModelPromptContentObservationFilter; +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 {@link ImageObservationAutoConfiguration}. + * + * @author Thomas Vitale + */ +class ImageObservationAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(ImageObservationAutoConfiguration.class)); + + @Test + void promptFilterDefault() { + this.contextRunner + .run(context -> assertThat(context).doesNotHaveBean(ImageModelPromptContentObservationFilter.class)); + } + + @Test + void promptFilterEnabled() { + this.contextRunner.withPropertyValues("spring.ai.image.observations.include-prompt=true") + .run(context -> assertThat(context).hasSingleBean(ImageModelPromptContentObservationFilter.class)); + } + +} diff --git a/pom.xml b/pom.xml index 2d1236bd4b3..f95d3448c6c 100644 --- a/pom.xml +++ b/pom.xml @@ -35,12 +35,41 @@ spring-ai-core spring-ai-test - spring-ai-spring-boot-autoconfigure + auto-configurations/common/spring-ai-retry-spring-boot-autoconfigure + + auto-configurations/chat/spring-ai-chat-client-spring-boot-autoconfigure + auto-configurations/chat/spring-ai-chat-model-spring-boot-autoconfigure + auto-configurations/chat/spring-ai-chat-memory-spring-boot-autoconfigure + + auto-configurations/models/spring-ai-anthropic-spring-boot-autoconfigure + auto-configurations/models/spring-ai-azure-openai-spring-boot-autoconfigure + auto-configurations/models/spring-ai-bedrock-spring-boot-autoconfigure + auto-configurations/models/spring-ai-huggingface-spring-boot-autoconfigure + auto-configurations/models/spring-ai-minimax-spring-boot-autoconfigure + auto-configurations/models/spring-ai-mistral-ai-spring-boot-autoconfigure + auto-configurations/models/spring-ai-moonshot-spring-boot-autoconfigure + auto-configurations/models/spring-ai-oci-genai-spring-boot-autoconfigure + auto-configurations/models/spring-ai-ollama-spring-boot-autoconfigure + auto-configurations/models/spring-ai-openai-spring-boot-autoconfigure + auto-configurations/models/spring-ai-postgresml-spring-boot-autoconfigure + auto-configurations/models/spring-ai-qianfan-spring-boot-autoconfigure + auto-configurations/models/spring-ai-stability-ai-spring-boot-autoconfigure + auto-configurations/models/spring-ai-transformers-spring-boot-autoconfigure + auto-configurations/models/spring-ai-vertex-ai-spring-boot-autoconfigure + auto-configurations/models/spring-ai-watsonx-ai-spring-boot-autoconfigure + auto-configurations/models/spring-ai-zhipuai-spring-boot-autoconfigure + + auto-configurations/observation/chat/spring-ai-chat-observation-spring-boot-autoconfigure + auto-configurations/observation/embedding/spring-ai-embedding-observation-spring-boot-autoconfigure + auto-configurations/observation/image/spring-ai-image-observation-spring-boot-autoconfigure + + auto-configurations/mcp/spring-ai-mcp-client + auto-configurations/mcp/spring-ai-mcp-server - auto-configurations/spring-ai-mcp-client - auto-configurations/spring-ai-mcp-server auto-configurations/vector-stores/spring-ai-weaviate-store-spring-boot-autoconfigure + spring-ai-spring-boot-autoconfigure + spring-ai-retry spring-ai-spring-boot-docker-compose spring-ai-spring-boot-testcontainers diff --git a/spring-ai-bom/pom.xml b/spring-ai-bom/pom.xml index 093caee4dc5..7b039bfc729 100644 --- a/spring-ai-bom/pom.xml +++ b/spring-ai-bom/pom.xml @@ -304,12 +304,44 @@ ${project.version} + org.springframework.ai spring-ai-spring-boot-autoconfigure ${project.version} + + + + + org.springframework.ai + spring-ai-retry-spring-boot-autoconfigure + ${project.version} + + + + + + org.springframework.ai + spring-ai-chat-client-spring-boot-autoconfigure + ${project.version} + + + + org.springframework.ai + spring-ai-chat-model-spring-boot-autoconfigure + ${project.version} + + + + org.springframework.ai + spring-ai-chat-memory-spring-boot-autoconfigure + ${project.version} + + + + org.springframework.ai spring-ai-mcp-client-spring-boot-autoconfigure @@ -322,6 +354,125 @@ ${project.version} + + + + org.springframework.ai + spring-ai-anthroic-spring-boot-autoconfigure + ${project.version} + + + + org.springframework.ai + spring-ai-azure-openai-spring-boot-autoconfigure + ${project.version} + + + + org.springframework.ai + spring-ai-bedrock-spring-boot-autoconfigure + ${project.version} + + + + org.springframework.ai + spring-ai-huggingface-spring-boot-autoconfigure + ${project.version} + + + + org.springframework.ai + spring-ai-minimax-spring-boot-autoconfigure + ${project.version} + + + + org.springframework.ai + spring-ai-mistral-ai-spring-boot-autoconfigure + ${project.version} + + + + org.springframework.ai + spring-ai-moonshot-spring-boot-autoconfigure + ${project.version} + + + + org.springframework.ai + spring-ai-oci-genai-spring-boot-autoconfigure + ${project.version} + + + + org.springframework.ai + spring-ai-ollama-spring-boot-autoconfigure + ${project.version} + + + + org.springframework.ai + spring-ai-openai-spring-boot-autoconfigure + ${project.version} + + + + org.springframework.ai + spring-ai-postgresml-spring-boot-autoconfigure + ${project.version} + + + + org.springframework.ai + spring-ai-qianfan-spring-boot-autoconfigure + ${project.version} + + + + org.springframework.ai + spring-ai-stability-ai-spring-boot-autoconfigure + ${project.version} + + + + org.springframework.ai + spring-ai-transformers-spring-boot-autoconfigure + ${project.version} + + + + org.springframework.ai + spring-ai-vertex-ai-spring-boot-autoconfigure + ${project.version} + + + + org.springframework.ai + spring-ai-watsonx-ai-spring-boot-autoconfigure + ${project.version} + + + + org.springframework.ai + spring-ai-zhipuai-spring-boot-autoconfigure + ${project.version} + + + + + + org.springframework.ai + spring-ai-chat-observation-spring-boot-autoconfigure + ${project.version} + + + + org.springframework.ai + spring-ai-embedding-observation-spring-boot-autoconfigure + ${project.version} + + + org.springframework.ai diff --git a/spring-ai-spring-boot-docker-compose/pom.xml b/spring-ai-spring-boot-docker-compose/pom.xml index 68e1b02e53d..c938d1310e5 100644 --- a/spring-ai-spring-boot-docker-compose/pom.xml +++ b/spring-ai-spring-boot-docker-compose/pom.xml @@ -41,6 +41,8 @@ + + org.springframework.ai spring-ai-spring-boot-autoconfigure diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-anthropic/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-anthropic/pom.xml index 31e711ba2b7..45e904cef78 100644 --- a/spring-ai-spring-boot-starters/spring-ai-starter-anthropic/pom.xml +++ b/spring-ai-spring-boot-starters/spring-ai-starter-anthropic/pom.xml @@ -26,7 +26,7 @@ spring-ai-anthropic-spring-boot-starter jar Spring AI Starter - Anthropic - Spring AI Anthropic Auto Configuration + Spring AI Anthropic Spring Boot Starter https://github.com/spring-projects/spring-ai @@ -44,7 +44,7 @@ org.springframework.ai - spring-ai-spring-boot-autoconfigure + spring-ai-anthropic-spring-boot-autoconfigure ${project.parent.version} diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-azure-openai/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-azure-openai/pom.xml index 7ccc8ef96bf..a856f1d1e19 100644 --- a/spring-ai-spring-boot-starters/spring-ai-starter-azure-openai/pom.xml +++ b/spring-ai-spring-boot-starters/spring-ai-starter-azure-openai/pom.xml @@ -26,7 +26,7 @@ spring-ai-azure-openai-spring-boot-starter jar Spring AI Starter - Azure OpenAI - Spring AI Azure OpenAI Auto Configuration + Spring AI Azure OpenAI Spring Boot Starter https://github.com/spring-projects/spring-ai @@ -44,7 +44,7 @@ org.springframework.ai - spring-ai-spring-boot-autoconfigure + spring-ai-azure-openai-spring-boot-autoconfigure ${project.parent.version} diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-bedrock-ai/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-bedrock-ai/pom.xml index fc95593d500..f858a5af6e4 100644 --- a/spring-ai-spring-boot-starters/spring-ai-starter-bedrock-ai/pom.xml +++ b/spring-ai-spring-boot-starters/spring-ai-starter-bedrock-ai/pom.xml @@ -27,7 +27,7 @@ spring-ai-bedrock-ai-spring-boot-starter jar Spring AI Starter - Bedrock AI - Spring AI Bedrock AI Auto Configuration + Spring AI Bedrock AI Spring Boot Starter https://github.com/spring-projects/spring-ai @@ -44,7 +44,7 @@ org.springframework.ai - spring-ai-spring-boot-autoconfigure + spring-ai-bedrock-spring-boot-autoconfigure ${project.parent.version} diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-bedrock-converse/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-bedrock-converse/pom.xml index e44dc1940b4..1780c301514 100644 --- a/spring-ai-spring-boot-starters/spring-ai-starter-bedrock-converse/pom.xml +++ b/spring-ai-spring-boot-starters/spring-ai-starter-bedrock-converse/pom.xml @@ -27,7 +27,7 @@ spring-ai-bedrock-converse-spring-boot-starter jar Spring AI Starter - Bedrock Converse API - Spring AI Bedrock Converse API Auto Configuration + Spring AI Bedrock Converse API Spring Boot Starter https://github.com/spring-projects/spring-ai @@ -44,7 +44,7 @@ org.springframework.ai - spring-ai-spring-boot-autoconfigure + spring-ai-bedrock-spring-boot-autoconfigure ${project.parent.version} diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-huggingface/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-huggingface/pom.xml index 0832116aa3c..a2bdd674dee 100644 --- a/spring-ai-spring-boot-starters/spring-ai-starter-huggingface/pom.xml +++ b/spring-ai-spring-boot-starters/spring-ai-starter-huggingface/pom.xml @@ -26,7 +26,7 @@ spring-ai-huggingface-spring-boot-starter jar Spring AI Starter - Hugging Face - Spring AI Hugging Face Starter + Spring AI Hugging Face Spring Boot Starter https://github.com/spring-projects/spring-ai @@ -44,7 +44,7 @@ org.springframework.ai - spring-ai-spring-boot-autoconfigure + spring-ai-huggingface-spring-boot-autoconfigure ${project.parent.version} diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-mcp-client-webflux/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-mcp-client-webflux/pom.xml index 0d2c8b210e8..531a90b25a6 100644 --- a/spring-ai-spring-boot-starters/spring-ai-starter-mcp-client-webflux/pom.xml +++ b/spring-ai-spring-boot-starters/spring-ai-starter-mcp-client-webflux/pom.xml @@ -28,7 +28,7 @@ spring-ai-mcp-client-webflux-spring-boot-starter jar Spring AI Starter - MCP Client Webflux - Spring AI MCP Client WebFlux Auto Configuration + Spring AI MCP Client WebFlux Spring Boot Starter https://github.com/spring-projects/spring-ai @@ -63,4 +63,4 @@ - \ No newline at end of file + diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-mcp-client/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-mcp-client/pom.xml index 06a22b85a89..acef845887c 100644 --- a/spring-ai-spring-boot-starters/spring-ai-starter-mcp-client/pom.xml +++ b/spring-ai-spring-boot-starters/spring-ai-starter-mcp-client/pom.xml @@ -26,7 +26,7 @@ spring-ai-mcp-client-spring-boot-starter jar Spring AI Starter - MCP Client - Spring AI MCP Client Auto Configuration + Spring AI MCP Client Spring Boot Starter https://github.com/spring-projects/spring-ai diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-mcp-server-webflux/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-mcp-server-webflux/pom.xml index e847b730fe9..ddb133f8fee 100644 --- a/spring-ai-spring-boot-starters/spring-ai-starter-mcp-server-webflux/pom.xml +++ b/spring-ai-spring-boot-starters/spring-ai-starter-mcp-server-webflux/pom.xml @@ -28,7 +28,7 @@ spring-ai-mcp-server-webflux-spring-boot-starter jar Spring AI Starter - MCP Server Webflux - Spring AI MCP Server WebFlux Auto Configuration + Spring AI MCP Server WebFlux Spring Boot Starter https://github.com/spring-projects/spring-ai @@ -69,4 +69,4 @@ - \ No newline at end of file + diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-mcp-server-webmvc/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-mcp-server-webmvc/pom.xml index 42d9a66a440..824b37007e6 100644 --- a/spring-ai-spring-boot-starters/spring-ai-starter-mcp-server-webmvc/pom.xml +++ b/spring-ai-spring-boot-starters/spring-ai-starter-mcp-server-webmvc/pom.xml @@ -28,7 +28,7 @@ spring-ai-mcp-server-webmvc-spring-boot-starter jar Spring AI Starter - MCP Server WebMvc - Spring AI MCP Server WebMvc Auto Configuration + Spring AI MCP Server WebMvc Spring Boot Starter https://github.com/spring-projects/spring-ai @@ -68,4 +68,4 @@ - \ No newline at end of file + diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-mcp-server/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-mcp-server/pom.xml index 15d0b79fc56..1d7dc35bbd5 100644 --- a/spring-ai-spring-boot-starters/spring-ai-starter-mcp-server/pom.xml +++ b/spring-ai-spring-boot-starters/spring-ai-starter-mcp-server/pom.xml @@ -26,7 +26,7 @@ spring-ai-mcp-server-spring-boot-starter jar Spring AI Starter - MCP Server - Spring AI MCP Server Auto Configuration + Spring AI MCP Server Spring Boot Starter https://github.com/spring-projects/spring-ai diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-minimax/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-minimax/pom.xml index 124570853ac..f2e737d8d6f 100644 --- a/spring-ai-spring-boot-starters/spring-ai-starter-minimax/pom.xml +++ b/spring-ai-spring-boot-starters/spring-ai-starter-minimax/pom.xml @@ -26,7 +26,7 @@ spring-ai-minimax-spring-boot-starter jar Spring AI Starter - MiniMax - Spring AI MiniMax Auto Configuration + Spring AI MiniMax Spring Boot Starter https://github.com/spring-projects/spring-ai @@ -44,7 +44,7 @@ org.springframework.ai - spring-ai-spring-boot-autoconfigure + spring-ai-minimax-spring-boot-autoconfigure ${project.parent.version} diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-mistral-ai/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-mistral-ai/pom.xml index 4c00f83262b..c0fa588c36d 100644 --- a/spring-ai-spring-boot-starters/spring-ai-starter-mistral-ai/pom.xml +++ b/spring-ai-spring-boot-starters/spring-ai-starter-mistral-ai/pom.xml @@ -26,7 +26,7 @@ spring-ai-mistral-ai-spring-boot-starter jar Spring AI Starter - MistralAI - Spring AI MistralAI Auto Configuration + Spring AI MistralAI Spring Boot Starter https://github.com/spring-projects/spring-ai @@ -44,7 +44,7 @@ org.springframework.ai - spring-ai-spring-boot-autoconfigure + spring-ai-mistral-ai-spring-boot-autoconfigure ${project.parent.version} diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-moonshot/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-moonshot/pom.xml index 70c34367bf0..01a3ef3cf62 100644 --- a/spring-ai-spring-boot-starters/spring-ai-starter-moonshot/pom.xml +++ b/spring-ai-spring-boot-starters/spring-ai-starter-moonshot/pom.xml @@ -26,7 +26,7 @@ spring-ai-moonshot-spring-boot-starter jar Spring AI Starter - Moonshot - Spring AI Moonshot Auto Configuration + Spring AI Moonshot Spring Boot Starter https://github.com/spring-projects/spring-ai @@ -44,7 +44,7 @@ org.springframework.ai - spring-ai-spring-boot-autoconfigure + spring-ai-moonshot-spring-boot-autoconfigure ${project.parent.version} diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-oci-genai/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-oci-genai/pom.xml index f8a83528786..9bd7c52c661 100644 --- a/spring-ai-spring-boot-starters/spring-ai-starter-oci-genai/pom.xml +++ b/spring-ai-spring-boot-starters/spring-ai-starter-oci-genai/pom.xml @@ -43,7 +43,7 @@ org.springframework.ai - spring-ai-spring-boot-autoconfigure + spring-ai-oci-genai-spring-boot-autoconfigure ${project.parent.version} diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-ollama/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-ollama/pom.xml index 7b16c0c672d..d3f67d565ed 100644 --- a/spring-ai-spring-boot-starters/spring-ai-starter-ollama/pom.xml +++ b/spring-ai-spring-boot-starters/spring-ai-starter-ollama/pom.xml @@ -26,7 +26,7 @@ spring-ai-ollama-spring-boot-starter jar Spring AI Starter - Ollama - Spring AI Ollama Auto Configuration + Spring AI Ollama Spring Boot Starter https://github.com/spring-projects/spring-ai @@ -44,7 +44,7 @@ org.springframework.ai - spring-ai-spring-boot-autoconfigure + spring-ai-ollama-spring-boot-autoconfigure ${project.parent.version} diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-openai/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-openai/pom.xml index a5bce988897..8a16fe673a6 100644 --- a/spring-ai-spring-boot-starters/spring-ai-starter-openai/pom.xml +++ b/spring-ai-spring-boot-starters/spring-ai-starter-openai/pom.xml @@ -26,7 +26,7 @@ spring-ai-openai-spring-boot-starter jar Spring AI Starter - OpenAI - Spring AI Open AI Auto Configuration + Spring AI Open AI Spring Boot Starter https://github.com/spring-projects/spring-ai @@ -44,7 +44,7 @@ org.springframework.ai - spring-ai-spring-boot-autoconfigure + spring-ai-ollama-spring-boot-autoconfigure ${project.parent.version} diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-postgresml-embedding/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-postgresml-embedding/pom.xml index ffbc4aa5543..26ef2e7e891 100644 --- a/spring-ai-spring-boot-starters/spring-ai-starter-postgresml-embedding/pom.xml +++ b/spring-ai-spring-boot-starters/spring-ai-starter-postgresml-embedding/pom.xml @@ -26,7 +26,7 @@ spring-ai-postgresml-spring-boot-starter jar Spring AI Starter - PostgresML Embedding - Spring PostgresML Embedding Auto Configuration + Spring PostgresML Embedding Spring Boot Starter https://github.com/spring-projects/spring-ai @@ -44,7 +44,7 @@ org.springframework.ai - spring-ai-spring-boot-autoconfigure + spring-ai-postgresml-spring-boot-autoconfigure ${project.parent.version} diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-qianfan/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-qianfan/pom.xml index 0da3b1776ae..25a08a0508b 100644 --- a/spring-ai-spring-boot-starters/spring-ai-starter-qianfan/pom.xml +++ b/spring-ai-spring-boot-starters/spring-ai-starter-qianfan/pom.xml @@ -26,7 +26,7 @@ spring-ai-qianfan-spring-boot-starter jar Spring AI Starter - QianFan - Spring AI QianFan Auto Configuration + Spring AI QianFan Spring Boot Starter https://github.com/spring-projects/spring-ai diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-stability-ai/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-stability-ai/pom.xml index 64764552e2b..97146ac98df 100644 --- a/spring-ai-spring-boot-starters/spring-ai-starter-stability-ai/pom.xml +++ b/spring-ai-spring-boot-starters/spring-ai-starter-stability-ai/pom.xml @@ -26,7 +26,7 @@ spring-ai-stability-ai-spring-boot-starter jar Spring AI Starter - Stability AI - Spring AI Stability Auto Configuration + Spring AI Stability Spring Boot Starter https://github.com/spring-projects/spring-ai @@ -44,7 +44,7 @@ org.springframework.ai - spring-ai-spring-boot-autoconfigure + spring-ai-stability-ai-spring-boot-autoconfigure ${project.parent.version} diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-transformers/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-transformers/pom.xml index 9b0e6164338..a57ca7c7047 100644 --- a/spring-ai-spring-boot-starters/spring-ai-starter-transformers/pom.xml +++ b/spring-ai-spring-boot-starters/spring-ai-starter-transformers/pom.xml @@ -26,7 +26,7 @@ spring-ai-transformers-spring-boot-starter jar Spring AI Starter - Transformers Embedding - Spring Transformers Embedding Auto Configuration + Spring AI Transformers Embedding Spring Boot Starter https://github.com/spring-projects/spring-ai @@ -44,7 +44,7 @@ org.springframework.ai - spring-ai-spring-boot-autoconfigure + spring-ai-transformers-spring-boot-autoconfigure ${project.parent.version} diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-vertex-ai-embedding/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-vertex-ai-embedding/pom.xml index 36fcee4d1e9..3b3bb8b5cf3 100644 --- a/spring-ai-spring-boot-starters/spring-ai-starter-vertex-ai-embedding/pom.xml +++ b/spring-ai-spring-boot-starters/spring-ai-starter-vertex-ai-embedding/pom.xml @@ -26,7 +26,7 @@ spring-ai-vertex-ai-embedding-spring-boot-starter jar Spring AI Starter - VertexAI Embedding - Spring AI Vertex Embedding AI Auto Configuration + Spring AI Vertex Embedding AI Spring Boot Starter https://github.com/spring-projects/spring-ai @@ -44,7 +44,7 @@ org.springframework.ai - spring-ai-spring-boot-autoconfigure + spring-ai-vertex-ai-spring-boot-autoconfigure ${project.parent.version} diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-vertex-ai-gemini/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-vertex-ai-gemini/pom.xml index 778b9cccde3..6d90f89b776 100644 --- a/spring-ai-spring-boot-starters/spring-ai-starter-vertex-ai-gemini/pom.xml +++ b/spring-ai-spring-boot-starters/spring-ai-starter-vertex-ai-gemini/pom.xml @@ -26,7 +26,7 @@ spring-ai-vertex-ai-gemini-spring-boot-starter jar Spring AI Starter - VertexAI Gemini - Spring AI Vertex Gemini AI Auto Configuration + Spring AI Vertex Gemini AI Spring Boot Starter https://github.com/spring-projects/spring-ai @@ -44,7 +44,7 @@ org.springframework.ai - spring-ai-spring-boot-autoconfigure + spring-ai-vertex-ai-spring-boot-autoconfigure ${project.parent.version} diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-watsonx-ai/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-watsonx-ai/pom.xml index 36e871422a4..44e7e8ac840 100644 --- a/spring-ai-spring-boot-starters/spring-ai-starter-watsonx-ai/pom.xml +++ b/spring-ai-spring-boot-starters/spring-ai-starter-watsonx-ai/pom.xml @@ -26,7 +26,7 @@ spring-ai-watsonx-ai-spring-boot-starter jar Spring AI Starter - Watsonx.AI - Spring AI Watsonx AI Auto Configuration + Spring AI Watsonx AI Spring Boot Starter https://github.com/spring-projects/spring-ai @@ -44,7 +44,7 @@ org.springframework.ai - spring-ai-spring-boot-autoconfigure + spring-ai-watsonx-ai-spring-boot-autoconfigure ${project.parent.version} @@ -55,4 +55,4 @@ - \ No newline at end of file + diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-zhipuai/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-zhipuai/pom.xml index 9eb4283c110..10554aff120 100644 --- a/spring-ai-spring-boot-starters/spring-ai-starter-zhipuai/pom.xml +++ b/spring-ai-spring-boot-starters/spring-ai-starter-zhipuai/pom.xml @@ -26,7 +26,7 @@ spring-ai-zhipuai-spring-boot-starter jar Spring AI Starter - ZhiPuAI - Spring AI ZhiPuAI Auto Configuration + Spring AI ZhiPuAI Spring Boot Starter https://github.com/spring-projects/spring-ai @@ -44,7 +44,7 @@ org.springframework.ai - spring-ai-spring-boot-autoconfigure + spring-ai-zhipuai-spring-boot-autoconfigure ${project.parent.version} diff --git a/spring-ai-spring-boot-testcontainers/pom.xml b/spring-ai-spring-boot-testcontainers/pom.xml index 11a8dda11af..7c192ff2588 100644 --- a/spring-ai-spring-boot-testcontainers/pom.xml +++ b/spring-ai-spring-boot-testcontainers/pom.xml @@ -42,6 +42,8 @@ + + org.springframework.ai spring-ai-spring-boot-autoconfigure