diff --git a/.gitignore b/.gitignore index 4e9567af1aa..05954172d63 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ target .classpath .project .settings +.env bin build.log integration-repo @@ -55,4 +56,3 @@ tmp plans - diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-anthropic/src/test/java/org/springframework/ai/model/anthropic/autoconfigure/tool/FunctionCallWithFunctionBeanIT.java b/auto-configurations/models/spring-ai-autoconfigure-model-anthropic/src/test/java/org/springframework/ai/model/anthropic/autoconfigure/tool/FunctionCallWithFunctionBeanIT.java index 03cf82807b6..b64e939e862 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-anthropic/src/test/java/org/springframework/ai/model/anthropic/autoconfigure/tool/FunctionCallWithFunctionBeanIT.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-anthropic/src/test/java/org/springframework/ai/model/anthropic/autoconfigure/tool/FunctionCallWithFunctionBeanIT.java @@ -118,7 +118,7 @@ public Function weatherFunction() { @Bean public Function weatherFunction3() { MockWeatherService weatherService = new MockWeatherService(); - return (weatherService::apply); + return weatherService::apply; } } diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/pom.xml b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/pom.xml new file mode 100644 index 00000000000..64989389387 --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/pom.xml @@ -0,0 +1,116 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai-parent + 2.0.0-SNAPSHOT + ../../../pom.xml + + spring-ai-autoconfigure-model-openai-sdk + jar + Spring AI OpenAI SDK Auto Configuration + Spring AI OpenAI SDK 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-sdk + ${project.parent.version} + + + + + + org.springframework.ai + spring-ai-autoconfigure-model-tool + ${project.parent.version} + + + + org.springframework.ai + spring-ai-autoconfigure-model-chat-observation + ${project.parent.version} + + + + org.springframework.ai + spring-ai-autoconfigure-model-embedding-observation + ${project.parent.version} + + + + org.springframework.ai + spring-ai-autoconfigure-model-image-observation + ${project.parent.version} + + + + + org.springframework.boot + spring-boot-starter + true + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + org.springframework.boot + spring-boot-autoconfigure-processor + true + + + + + + org.jetbrains.kotlin + kotlin-reflect + true + + + + + org.springframework.ai + spring-ai-test + ${project.parent.version} + test + + + + org.springframework.ai + spring-ai-autoconfigure-model-chat-client + ${project.parent.version} + test + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.mockito + mockito-core + test + + + + diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/main/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkAutoConfigurationUtil.java b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/main/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkAutoConfigurationUtil.java new file mode 100644 index 00000000000..51d15ebfca4 --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/main/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkAutoConfigurationUtil.java @@ -0,0 +1,81 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.model.openaisdk.autoconfigure; + +import org.springframework.ai.openaisdk.AbstractOpenAiSdkOptions; +import org.springframework.util.StringUtils; + +public final class OpenAiSdkAutoConfigurationUtil { + + private OpenAiSdkAutoConfigurationUtil() { + // Avoids instantiation + } + + public static ResolvedConnectionProperties resolveConnectionProperties(AbstractOpenAiSdkOptions commonProperties, + AbstractOpenAiSdkOptions modelProperties) { + + var resolved = new ResolvedConnectionProperties(); + + resolved.setBaseUrl(StringUtils.hasText(modelProperties.getBaseUrl()) ? modelProperties.getBaseUrl() + : commonProperties.getBaseUrl()); + + resolved.setApiKey(StringUtils.hasText(modelProperties.getApiKey()) ? modelProperties.getApiKey() + : commonProperties.getApiKey()); + + String organizationId = StringUtils.hasText(modelProperties.getOrganizationId()) + ? modelProperties.getOrganizationId() : commonProperties.getOrganizationId(); + resolved.setOrganizationId(organizationId); + + resolved.setCredential(modelProperties.getCredential() != null ? modelProperties.getCredential() + : commonProperties.getCredential()); + + resolved.setTimeout( + modelProperties.getTimeout() != null ? modelProperties.getTimeout() : commonProperties.getTimeout()); + + resolved.setModel(StringUtils.hasText(modelProperties.getModel()) ? modelProperties.getModel() + : commonProperties.getModel()); + + resolved.setMicrosoftDeploymentName(StringUtils.hasText(modelProperties.getMicrosoftDeploymentName()) + ? modelProperties.getMicrosoftDeploymentName() : commonProperties.getMicrosoftDeploymentName()); + + resolved.setMicrosoftFoundryServiceVersion(modelProperties.getMicrosoftFoundryServiceVersion() != null + ? modelProperties.getMicrosoftFoundryServiceVersion() + : commonProperties.getMicrosoftFoundryServiceVersion()); + + // For boolean properties, use modelProperties value, defaulting to + // commonProperties if needed + resolved.setMicrosoftFoundry(modelProperties.isMicrosoftFoundry() || commonProperties.isMicrosoftFoundry()); + + resolved.setGitHubModels(modelProperties.isGitHubModels() || commonProperties.isGitHubModels()); + + resolved.setMaxRetries(modelProperties.getMaxRetries() != null ? modelProperties.getMaxRetries() + : commonProperties.getMaxRetries()); + + resolved + .setProxy(modelProperties.getProxy() != null ? modelProperties.getProxy() : commonProperties.getProxy()); + + resolved.setCustomHeaders(modelProperties.getCustomHeaders() != null ? modelProperties.getCustomHeaders() + : commonProperties.getCustomHeaders()); + + return resolved; + } + + public static class ResolvedConnectionProperties extends AbstractOpenAiSdkOptions { + + } + +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/main/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkChatAutoConfiguration.java b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/main/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkChatAutoConfiguration.java new file mode 100644 index 00000000000..986342b7031 --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/main/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkChatAutoConfiguration.java @@ -0,0 +1,93 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.model.openaisdk.autoconfigure; + +import com.openai.client.OpenAIClient; +import com.openai.client.OpenAIClientAsync; +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.ai.chat.observation.ChatModelObservationConvention; +import org.springframework.ai.model.SpringAIModelProperties; +import org.springframework.ai.model.SpringAIModels; +import org.springframework.ai.model.tool.DefaultToolExecutionEligibilityPredicate; +import org.springframework.ai.model.tool.ToolCallingManager; +import org.springframework.ai.model.tool.ToolExecutionEligibilityPredicate; +import org.springframework.ai.model.tool.autoconfigure.ToolCallingAutoConfiguration; +import org.springframework.ai.openaisdk.AbstractOpenAiSdkOptions; +import org.springframework.ai.openaisdk.OpenAiSdkChatModel; +import org.springframework.ai.openaisdk.setup.OpenAiSdkSetup; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +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; + +/** + * Chat {@link AutoConfiguration Auto-configuration} for OpenAI SDK. + * + * @author Christian Tzolov + */ +@AutoConfiguration(after = { ToolCallingAutoConfiguration.class }) +@EnableConfigurationProperties({ OpenAiSdkConnectionProperties.class, OpenAiSdkChatProperties.class }) +@ConditionalOnProperty(name = SpringAIModelProperties.CHAT_MODEL, havingValue = SpringAIModels.OPENAI_SDK, + matchIfMissing = true) +public class OpenAiSdkChatAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public OpenAiSdkChatModel openAiChatModel(OpenAiSdkConnectionProperties commonProperties, + OpenAiSdkChatProperties chatProperties, ToolCallingManager toolCallingManager, + ObjectProvider observationRegistry, + ObjectProvider observationConvention, + ObjectProvider openAiToolExecutionEligibilityPredicate) { + + OpenAiSdkAutoConfigurationUtil.ResolvedConnectionProperties resolvedConnectionProperties = OpenAiSdkAutoConfigurationUtil + .resolveConnectionProperties(commonProperties, chatProperties); + + OpenAIClient openAIClient = this.openAiClient(resolvedConnectionProperties); + + OpenAIClientAsync openAIClientAsync = this.openAiClientAsync(resolvedConnectionProperties); + + var chatModel = new OpenAiSdkChatModel(openAIClient, openAIClientAsync, chatProperties.getOptions(), + toolCallingManager, observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP), + openAiToolExecutionEligibilityPredicate.getIfUnique(DefaultToolExecutionEligibilityPredicate::new)); + + observationConvention.ifAvailable(chatModel::setObservationConvention); + + return chatModel; + } + + private OpenAIClient openAiClient(AbstractOpenAiSdkOptions resolved) { + + return OpenAiSdkSetup.setupSyncClient(resolved.getBaseUrl(), resolved.getApiKey(), resolved.getCredential(), + resolved.getMicrosoftDeploymentName(), resolved.getMicrosoftFoundryServiceVersion(), + resolved.getOrganizationId(), resolved.isMicrosoftFoundry(), resolved.isGitHubModels(), + resolved.getModel(), resolved.getTimeout(), resolved.getMaxRetries(), resolved.getProxy(), + resolved.getCustomHeaders()); + } + + private OpenAIClientAsync openAiClientAsync(AbstractOpenAiSdkOptions resolved) { + + return OpenAiSdkSetup.setupAsyncClient(resolved.getBaseUrl(), resolved.getApiKey(), resolved.getCredential(), + resolved.getMicrosoftDeploymentName(), resolved.getMicrosoftFoundryServiceVersion(), + resolved.getOrganizationId(), resolved.isMicrosoftFoundry(), resolved.isGitHubModels(), + resolved.getModel(), resolved.getTimeout(), resolved.getMaxRetries(), resolved.getProxy(), + resolved.getCustomHeaders()); + } + +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/main/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkChatProperties.java b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/main/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkChatProperties.java new file mode 100644 index 00000000000..717c9bcd25b --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/main/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkChatProperties.java @@ -0,0 +1,48 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.model.openaisdk.autoconfigure; + +import org.springframework.ai.openaisdk.AbstractOpenAiSdkOptions; +import org.springframework.ai.openaisdk.OpenAiSdkChatOptions; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * OpenAI SDK Chat autoconfiguration properties. + * + * @author Christian Tzolov + */ +@ConfigurationProperties(OpenAiSdkChatProperties.CONFIG_PREFIX) +public class OpenAiSdkChatProperties extends AbstractOpenAiSdkOptions { + + public static final String CONFIG_PREFIX = "spring.ai.openai-sdk.chat"; + + public static final String DEFAULT_CHAT_MODEL = OpenAiSdkChatOptions.DEFAULT_CHAT_MODEL; + + private static final Double DEFAULT_TEMPERATURE = 1.0; + + @NestedConfigurationProperty + private final OpenAiSdkChatOptions options = OpenAiSdkChatOptions.builder() + .model(DEFAULT_CHAT_MODEL) + .temperature(DEFAULT_TEMPERATURE) + .build(); + + public OpenAiSdkChatOptions getOptions() { + return this.options; + } + +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/main/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkConnectionProperties.java b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/main/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkConnectionProperties.java new file mode 100644 index 00000000000..2c5c3b54bae --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/main/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkConnectionProperties.java @@ -0,0 +1,27 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.model.openaisdk.autoconfigure; + +import org.springframework.ai.openaisdk.AbstractOpenAiSdkOptions; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(OpenAiSdkConnectionProperties.CONFIG_PREFIX) +public class OpenAiSdkConnectionProperties extends AbstractOpenAiSdkOptions { + + public static final String CONFIG_PREFIX = "spring.ai.openai-sdk"; + +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/main/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkEmbeddingAutoConfiguration.java b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/main/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkEmbeddingAutoConfiguration.java new file mode 100644 index 00000000000..6e8f7c4e567 --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/main/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkEmbeddingAutoConfiguration.java @@ -0,0 +1,73 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.model.openaisdk.autoconfigure; + +import com.openai.client.OpenAIClient; +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.ai.embedding.observation.EmbeddingModelObservationConvention; +import org.springframework.ai.model.SpringAIModelProperties; +import org.springframework.ai.model.SpringAIModels; +import org.springframework.ai.openaisdk.OpenAiSdkEmbeddingModel; +import org.springframework.ai.openaisdk.setup.OpenAiSdkSetup; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +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; + +/** + * Embedding {@link AutoConfiguration Auto-configuration} for OpenAI SDK. + * + * @author Christian Tzolov + */ +@AutoConfiguration +@ConditionalOnProperty(name = SpringAIModelProperties.EMBEDDING_MODEL, havingValue = SpringAIModels.OPENAI_SDK, + matchIfMissing = true) +@EnableConfigurationProperties({ OpenAiSdkConnectionProperties.class, OpenAiSdkEmbeddingProperties.class }) +public class OpenAiSdkEmbeddingAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public OpenAiSdkEmbeddingModel openAiEmbeddingModel(OpenAiSdkConnectionProperties commonProperties, + OpenAiSdkEmbeddingProperties embeddingProperties, ObjectProvider observationRegistry, + ObjectProvider observationConvention) { + + var embeddingModel = new OpenAiSdkEmbeddingModel(this.openAiClient(commonProperties, embeddingProperties), + embeddingProperties.getMetadataMode(), embeddingProperties.getOptions(), + observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)); + + observationConvention.ifAvailable(embeddingModel::setObservationConvention); + + return embeddingModel; + } + + private OpenAIClient openAiClient(OpenAiSdkConnectionProperties commonProperties, + OpenAiSdkEmbeddingProperties embeddingProperties) { + + OpenAiSdkAutoConfigurationUtil.ResolvedConnectionProperties resolved = OpenAiSdkAutoConfigurationUtil + .resolveConnectionProperties(commonProperties, embeddingProperties); + + return OpenAiSdkSetup.setupSyncClient(resolved.getBaseUrl(), resolved.getApiKey(), resolved.getCredential(), + resolved.getMicrosoftDeploymentName(), resolved.getMicrosoftFoundryServiceVersion(), + resolved.getOrganizationId(), resolved.isMicrosoftFoundry(), resolved.isGitHubModels(), + resolved.getModel(), resolved.getTimeout(), resolved.getMaxRetries(), resolved.getProxy(), + resolved.getCustomHeaders()); + } + +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/main/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkEmbeddingProperties.java b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/main/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkEmbeddingProperties.java new file mode 100644 index 00000000000..ee3a648d3ca --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/main/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkEmbeddingProperties.java @@ -0,0 +1,51 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.model.openaisdk.autoconfigure; + +import org.springframework.ai.document.MetadataMode; +import org.springframework.ai.openaisdk.AbstractOpenAiSdkOptions; +import org.springframework.ai.openaisdk.OpenAiSdkEmbeddingOptions; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +@ConfigurationProperties(OpenAiSdkEmbeddingProperties.CONFIG_PREFIX) +public class OpenAiSdkEmbeddingProperties extends AbstractOpenAiSdkOptions { + + public static final String CONFIG_PREFIX = "spring.ai.openai-sdk.embedding"; + + public static final String DEFAULT_EMBEDDING_MODEL = OpenAiSdkEmbeddingOptions.DEFAULT_EMBEDDING_MODEL; + + private MetadataMode metadataMode = MetadataMode.EMBED; + + @NestedConfigurationProperty + private final OpenAiSdkEmbeddingOptions options = OpenAiSdkEmbeddingOptions.builder() + .model(DEFAULT_EMBEDDING_MODEL) + .build(); + + public OpenAiSdkEmbeddingOptions getOptions() { + return this.options; + } + + public MetadataMode getMetadataMode() { + return this.metadataMode; + } + + public void setMetadataMode(MetadataMode metadataMode) { + this.metadataMode = metadataMode; + } + +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/main/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkImageAutoConfiguration.java b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/main/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkImageAutoConfiguration.java new file mode 100644 index 00000000000..d15fe2bcbb1 --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/main/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkImageAutoConfiguration.java @@ -0,0 +1,72 @@ +/* + * 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.model.openaisdk.autoconfigure; + +import com.openai.client.OpenAIClient; +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.ai.image.observation.ImageModelObservationConvention; +import org.springframework.ai.model.SpringAIModelProperties; +import org.springframework.ai.model.SpringAIModels; +import org.springframework.ai.openaisdk.OpenAiSdkImageModel; +import org.springframework.ai.openaisdk.setup.OpenAiSdkSetup; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +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; + +/** + * Image {@link AutoConfiguration Auto-configuration} for OpenAI. + * + * @author Christian Tzolov + */ +@AutoConfiguration +@ConditionalOnProperty(name = SpringAIModelProperties.IMAGE_MODEL, havingValue = SpringAIModels.OPENAI_SDK, + matchIfMissing = true) +@EnableConfigurationProperties({ OpenAiSdkConnectionProperties.class, OpenAiSdkImageProperties.class }) +public class OpenAiSdkImageAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public OpenAiSdkImageModel openAiImageModel(OpenAiSdkConnectionProperties commonProperties, + OpenAiSdkImageProperties imageProperties, ObjectProvider observationRegistry, + ObjectProvider observationConvention) { + + var imageModel = new OpenAiSdkImageModel(openAiClient(commonProperties, imageProperties), + imageProperties.getOptions(), observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)); + + observationConvention.ifAvailable(imageModel::setObservationConvention); + + return imageModel; + } + + private OpenAIClient openAiClient(OpenAiSdkConnectionProperties commonProperties, + OpenAiSdkImageProperties imageProperties) { + + OpenAiSdkAutoConfigurationUtil.ResolvedConnectionProperties resolved = OpenAiSdkAutoConfigurationUtil + .resolveConnectionProperties(commonProperties, imageProperties); + + return OpenAiSdkSetup.setupSyncClient(resolved.getBaseUrl(), resolved.getApiKey(), resolved.getCredential(), + resolved.getMicrosoftDeploymentName(), resolved.getMicrosoftFoundryServiceVersion(), + resolved.getOrganizationId(), resolved.isMicrosoftFoundry(), resolved.isGitHubModels(), + resolved.getModel(), resolved.getTimeout(), resolved.getMaxRetries(), resolved.getProxy(), + resolved.getCustomHeaders()); + } + +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/main/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkImageProperties.java b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/main/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkImageProperties.java new file mode 100644 index 00000000000..7323de414c5 --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/main/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkImageProperties.java @@ -0,0 +1,48 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.model.openaisdk.autoconfigure; + +import com.openai.models.images.ImageModel; + +import org.springframework.ai.openaisdk.AbstractOpenAiSdkOptions; +import org.springframework.ai.openaisdk.OpenAiSdkImageOptions; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * OpenAI SDK Image autoconfiguration properties. + * + * @author Christian Tzolov + */ +@ConfigurationProperties(OpenAiSdkImageProperties.CONFIG_PREFIX) +public class OpenAiSdkImageProperties extends AbstractOpenAiSdkOptions { + + public static final String CONFIG_PREFIX = "spring.ai.openai-sdk.image"; + + public static final String DEFAULT_IMAGE_MODEL = ImageModel.DALL_E_3.toString(); + + /** + * Options for OpenAI Sdk Image API. + */ + @NestedConfigurationProperty + private final OpenAiSdkImageOptions options = OpenAiSdkImageOptions.builder().model(DEFAULT_IMAGE_MODEL).build(); + + public OpenAiSdkImageOptions getOptions() { + return this.options; + } + +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/main/java/org/springframework/ai/model/openaisdk/autoconfigure/package-info.java b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/main/java/org/springframework/ai/model/openaisdk/autoconfigure/package-info.java new file mode 100644 index 00000000000..71b622244c1 --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/main/java/org/springframework/ai/model/openaisdk/autoconfigure/package-info.java @@ -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. + */ + +package org.springframework.ai.model.openaisdk.autoconfigure; diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..78529ac9555 --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,19 @@ +# +# 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.model.openaisdk.autoconfigure.OpenAiSdkChatAutoConfiguration +org.springframework.ai.model.openaisdk.autoconfigure.OpenAiSdkEmbeddingAutoConfiguration +org.springframework.ai.model.openaisdk.autoconfigure.OpenAiSdkImageAutoConfiguration + diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/test/java/org/springframework/ai/model/openaisdk/autoconfigure/ChatClientAutoConfigurationIT.java b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/test/java/org/springframework/ai/model/openaisdk/autoconfigure/ChatClientAutoConfigurationIT.java new file mode 100644 index 00000000000..7cf2ab27f71 --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/test/java/org/springframework/ai/model/openaisdk/autoconfigure/ChatClientAutoConfigurationIT.java @@ -0,0 +1,119 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.model.openaisdk.autoconfigure; + +import java.util.List; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.ChatClientCustomizer; +import org.springframework.ai.model.chat.client.autoconfigure.ChatClientAutoConfiguration; +import org.springframework.ai.utils.SpringAiTestAutoConfigurations; +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-sdk.apiKey=" + System.getenv("OPENAI_API_KEY"), + "spring.ai.openai-sdk.chat.options.model=gpt-4o") + .withConfiguration(SpringAiTestAutoConfigurations.of(OpenAiSdkChatAutoConfiguration.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-autoconfigure-model-openai-sdk/src/test/java/org/springframework/ai/model/openaisdk/autoconfigure/MockWeatherService.java b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/test/java/org/springframework/ai/model/openaisdk/autoconfigure/MockWeatherService.java new file mode 100644 index 00000000000..ee804783417 --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/test/java/org/springframework/ai/model/openaisdk/autoconfigure/MockWeatherService.java @@ -0,0 +1,97 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.model.openaisdk.autoconfigure; + +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-autoconfigure-model-openai-sdk/src/test/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiFunctionCallback2IT.java b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/test/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiFunctionCallback2IT.java new file mode 100644 index 00000000000..564e172286b --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/test/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiFunctionCallback2IT.java @@ -0,0 +1,111 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.model.openaisdk.autoconfigure; + +import java.util.stream.Collectors; + +import com.openai.models.ChatModel; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.openaisdk.OpenAiSdkChatModel; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.function.FunctionToolCallback; +import org.springframework.ai.utils.SpringAiTestAutoConfigurations; +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-sdk.apiKey=" + System.getenv("OPENAI_API_KEY")) + .withConfiguration(SpringAiTestAutoConfigurations.of(OpenAiSdkChatAutoConfiguration.class)) + .withUserConfiguration(Config.class); + + @Test + void functionCallTest() { + this.contextRunner + .withPropertyValues("spring.ai.openai-sdk.chat.options.temperature=0.1", + "spring.ai.openai-sdk.chat.options.model=" + ChatModel.GPT_4O_MINI.asString()) + .run(context -> { + + OpenAiSdkChatModel chatModel = context.getBean(OpenAiSdkChatModel.class); + + // @formatter:off + ChatClient chatClient = ChatClient.builder(chatModel) + .defaultToolNames("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-sdk.chat.options.temperature=0.2", + "spring.ai.openai-sdk.chat.options.model=" + ChatModel.GPT_4O_MINI.asString()) + .run(context -> { + + OpenAiSdkChatModel chatModel = context.getBean(OpenAiSdkChatModel.class); + + // @formatter:off + String content = ChatClient.builder(chatModel).build().prompt() + .toolNames("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-autoconfigure-model-openai-sdk/src/test/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkChatAutoConfigurationIT.java b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/test/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkChatAutoConfigurationIT.java new file mode 100644 index 00000000000..57f73a806a3 --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/test/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkChatAutoConfigurationIT.java @@ -0,0 +1,129 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.model.openaisdk.autoconfigure; + +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.openaisdk.OpenAiSdkChatModel; +import org.springframework.ai.utils.SpringAiTestAutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".*") +public class OpenAiSdkChatAutoConfigurationIT { + + private static final Log logger = LogFactory.getLog(OpenAiSdkChatAutoConfigurationIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai-sdk.apiKey=" + System.getenv("OPENAI_API_KEY")); + + @Test + void chatCall() { + this.contextRunner.withConfiguration(SpringAiTestAutoConfigurations.of(OpenAiSdkChatAutoConfiguration.class)) + .run(context -> { + OpenAiSdkChatModel chatModel = context.getBean(OpenAiSdkChatModel.class); + String response = chatModel.call("Hello"); + assertThat(response).isNotEmpty(); + logger.info("Response: " + response); + }); + } + + @Test + void generateStreaming() { + this.contextRunner.withConfiguration(SpringAiTestAutoConfigurations.of(OpenAiSdkChatAutoConfiguration.class)) + .run(context -> { + OpenAiSdkChatModel chatModel = context.getBean(OpenAiSdkChatModel.class); + Flux responseFlux = chatModel.stream(new Prompt(new UserMessage("Hello"))); + String response = responseFlux.collectList() + .block() + .stream() + .map(chatResponse -> chatResponse.getResult() != null + ? chatResponse.getResult().getOutput().getText() : "") + .collect(Collectors.joining()); + + assertThat(response).isNotEmpty(); + logger.info("Response: " + response); + }); + } + + @Test + void streamingWithTokenUsage() { + this.contextRunner.withPropertyValues("spring.ai.openai-sdk.chat.options.stream-usage=true") + .withConfiguration(SpringAiTestAutoConfigurations.of(OpenAiSdkChatAutoConfiguration.class)) + .run(context -> { + OpenAiSdkChatModel chatModel = context.getBean(OpenAiSdkChatModel.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 chatActivation() { + this.contextRunner + .withPropertyValues("spring.ai.openai-sdk.api-key=API_KEY", "spring.ai.openai-sdk.base-url=TEST_BASE_URL", + "spring.ai.model.chat=none") + .withConfiguration(SpringAiTestAutoConfigurations.of(OpenAiSdkChatAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiSdkChatProperties.class)).isEmpty(); + assertThat(context.getBeansOfType(OpenAiSdkChatModel.class)).isEmpty(); + }); + + this.contextRunner + .withPropertyValues("spring.ai.openai-sdk.api-key=API_KEY", + "spring.ai.openai-sdk.base-url=http://test.base.url") + .withConfiguration(SpringAiTestAutoConfigurations.of(OpenAiSdkChatAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiSdkChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiSdkChatModel.class)).isNotEmpty(); + }); + + this.contextRunner + .withPropertyValues("spring.ai.openai-sdk.api-key=API_KEY", + "spring.ai.openai-sdk.base-url=http://test.base.url", "spring.ai.model.chat=openai-sdk") + .withConfiguration(SpringAiTestAutoConfigurations.of(OpenAiSdkChatAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiSdkChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiSdkChatModel.class)).isNotEmpty(); + }); + + } + +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/test/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkChatPropertiesTests.java b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/test/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkChatPropertiesTests.java new file mode 100644 index 00000000000..bcf79a15c39 --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/test/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkChatPropertiesTests.java @@ -0,0 +1,149 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.model.openaisdk.autoconfigure; + +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; +import org.skyscreamer.jsonassert.JSONCompareMode; + +import org.springframework.ai.utils.SpringAiTestAutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit Tests for {@link OpenAiConnectionProperties}, {@link OpenAiSdkChatProperties} and + * {@link OpenAiSdkEmbeddingProperties}. + * + * @author Christian Tzolov + */ +public class OpenAiSdkChatPropertiesTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner(); + + @Test + public void chatProperties() { + + this.contextRunner.withPropertyValues( + // @formatter:off + "spring.ai.openai-sdk.base-url=http://TEST.BASE.URL", + "spring.ai.openai-sdk.api-key=abc123", + "spring.ai.openai-sdk.chat.options.model=MODEL_XYZ", + "spring.ai.openai-sdk.chat.options.temperature=0.55") + // @formatter:on + .withConfiguration(SpringAiTestAutoConfigurations.of(OpenAiSdkChatAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(OpenAiSdkChatProperties.class); + var connectionProperties = context.getBean(OpenAiSdkConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("http://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() { + + this.contextRunner.withPropertyValues( + // @formatter:off + "spring.ai.openai-sdk.base-url=http://TEST.BASE.URL", + "spring.ai.openai-sdk.api-key=abc123", + "spring.ai.openai-sdk.chat.base-url=http://TEST.BASE.URL2", + "spring.ai.openai-sdk.chat.api-key=456", + "spring.ai.openai-sdk.chat.options.model=MODEL_XYZ", + "spring.ai.openai-sdk.chat.options.temperature=0.55") + // @formatter:on + .withConfiguration(SpringAiTestAutoConfigurations.of(OpenAiSdkChatAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(OpenAiSdkChatProperties.class); + var connectionProperties = context.getBean(OpenAiSdkConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("http://TEST.BASE.URL"); + + assertThat(chatProperties.getApiKey()).isEqualTo("456"); + assertThat(chatProperties.getBaseUrl()).isEqualTo("http://TEST.BASE.URL2"); + + assertThat(chatProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55); + }); + } + + @Test + public void chatOptionsTest() { + + this.contextRunner + .withPropertyValues(// @formatter:off + "spring.ai.openai-sdk.api-key=API_KEY", + "spring.ai.openai-sdk.base-url=http://TEST.BASE.URL", + + "spring.ai.openai-sdk.chat.options.model=MODEL_XYZ", + "spring.ai.openai-sdk.chat.options.frequencyPenalty=-1.5", + "spring.ai.openai-sdk.chat.options.logitBias.myTokenId=-5", + "spring.ai.openai-sdk.chat.options.maxTokens=123", + "spring.ai.openai-sdk.chat.options.n=10", + "spring.ai.openai-sdk.chat.options.presencePenalty=0", + "spring.ai.openai-sdk.chat.options.seed=66", + "spring.ai.openai-sdk.chat.options.stop=boza,koza", + "spring.ai.openai-sdk.chat.options.temperature=0.55", + "spring.ai.openai-sdk.chat.options.topP=0.56", + "spring.ai.openai-sdk.chat.options.user=userXYZ", + "spring.ai.openai-sdk.chat.options.toolChoice={\"type\":\"function\",\"function\":{\"name\":\"toolChoiceFunctionName\"}}", + "spring.ai.openai-sdk.chat.options.streamOptions.includeUsage=true", + "spring.ai.openai-sdk.chat.options.streamOptions.includeObfuscation=true", + "spring.ai.openai-sdk.chat.options.streamOptions.additionalProperties.foo=bar" + + ) + // @formatter:on + .withConfiguration(SpringAiTestAutoConfigurations.of(OpenAiSdkChatAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(OpenAiSdkChatProperties.class); + var connectionProperties = context.getBean(OpenAiSdkConnectionProperties.class); + + assertThat(connectionProperties.getBaseUrl()).isEqualTo("http://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().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().getStreamOptions()).isNotNull(); + assertThat(chatProperties.getOptions().getStreamOptions().includeObfuscation()).isTrue(); + assertThat(chatProperties.getOptions().getStreamOptions().additionalProperties().get("foo")) + .isEqualTo("bar"); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/test/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkEmbeddingAutoConfigurationIT.java b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/test/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkEmbeddingAutoConfigurationIT.java new file mode 100644 index 00000000000..46295a307ad --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/test/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkEmbeddingAutoConfigurationIT.java @@ -0,0 +1,113 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.model.openaisdk.autoconfigure; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.ai.openaisdk.OpenAiSdkEmbeddingModel; +import org.springframework.ai.utils.SpringAiTestAutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".*") +public class OpenAiSdkEmbeddingAutoConfigurationIT { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai-sdk.apiKey=" + System.getenv("OPENAI_API_KEY")); + + @Test + void embedding() { + this.contextRunner + .withConfiguration(SpringAiTestAutoConfigurations.of(OpenAiSdkEmbeddingAutoConfiguration.class)) + .run(context -> { + OpenAiSdkEmbeddingModel embeddingModel = context.getBean(OpenAiSdkEmbeddingModel.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 embeddingActivation() { + + this.contextRunner + .withPropertyValues("spring.ai.openai-sdk.api-key=API_KEY", "spring.ai.openai-sdk.base-url=TEST_BASE_URL", + "spring.ai.model.embedding=none") + .withConfiguration(SpringAiTestAutoConfigurations.of(OpenAiSdkEmbeddingAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiSdkEmbeddingProperties.class)).isEmpty(); + assertThat(context.getBeansOfType(OpenAiSdkEmbeddingModel.class)).isEmpty(); + }); + + this.contextRunner + .withPropertyValues("spring.ai.openai-sdk.api-key=API_KEY", + "spring.ai.openai-sdk.base-url=http://TEST.BASE.URL") + .withConfiguration(SpringAiTestAutoConfigurations.of(OpenAiSdkEmbeddingAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiSdkEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiSdkEmbeddingModel.class)).isNotEmpty(); + }); + + this.contextRunner + .withPropertyValues("spring.ai.openai-sdk.api-key=API_KEY", + "spring.ai.openai-sdk.base-url=http://TEST.BASE.URL", "spring.ai.model.embedding=openai-sdk") + .withConfiguration(SpringAiTestAutoConfigurations.of(OpenAiSdkEmbeddingAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiSdkEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiSdkEmbeddingModel.class)).isNotEmpty(); + }); + } + + @Test + public void embeddingOptionsTest() { + + this.contextRunner.withPropertyValues( + // @formatter:off + "spring.ai.openai-sdk.api-key=API_KEY", + "spring.ai.openai-sdk.base-url=http://TEST.BASE.URL", + + "spring.ai.openai-sdk.embedding.options.model=MODEL_XYZ", + "spring.ai.openai-sdk.embedding.options.encodingFormat=MyEncodingFormat", + "spring.ai.openai-sdk.embedding.options.user=userXYZ" + ) + // @formatter:on + .withConfiguration(SpringAiTestAutoConfigurations.of(OpenAiSdkEmbeddingAutoConfiguration.class)) + .run(context -> { + var connectionProperties = context.getBean(OpenAiSdkConnectionProperties.class); + var embeddingProperties = context.getBean(OpenAiSdkEmbeddingProperties.class); + + assertThat(connectionProperties.getBaseUrl()).isEqualTo("http://TEST.BASE.URL"); + assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY"); + + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(embeddingProperties.getOptions().getUser()).isEqualTo("userXYZ"); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/test/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkEmbeddingPropertiesTests.java b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/test/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkEmbeddingPropertiesTests.java new file mode 100644 index 00000000000..ac807702f74 --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/test/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkEmbeddingPropertiesTests.java @@ -0,0 +1,120 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.model.openaisdk.autoconfigure; + +import org.junit.jupiter.api.Test; + +import org.springframework.ai.document.MetadataMode; +import org.springframework.ai.utils.SpringAiTestAutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit Tests for {@link OpenAiConnectionProperties} and + * {@link OpenAiSdkEmbeddingProperties}. + * + * @author Christian Tzolov + */ +public class OpenAiSdkEmbeddingPropertiesTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner(); + + @Test + public void embeddingProperties() { + + this.contextRunner.withPropertyValues( + // @formatter:off + "spring.ai.openai-sdk.base-url=http://TEST.BASE.URL", + "spring.ai.openai-sdk.api-key=abc123", + "spring.ai.openai-sdk.embedding.options.model=MODEL_XYZ", + "spring.ai.openai-sdk.embedding.options.dimensions=512") + // @formatter:on + .withConfiguration(SpringAiTestAutoConfigurations.of(OpenAiSdkEmbeddingAutoConfiguration.class)) + .run(context -> { + var embeddingProperties = context.getBean(OpenAiSdkEmbeddingProperties.class); + var connectionProperties = context.getBean(OpenAiSdkConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("http://TEST.BASE.URL"); + + assertThat(embeddingProperties.getApiKey()).isNull(); + assertThat(embeddingProperties.getBaseUrl()).isNull(); + + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(embeddingProperties.getOptions().getDimensions()).isEqualTo(512); + }); + } + + @Test + public void embeddingOverrideConnectionProperties() { + + this.contextRunner.withPropertyValues( + // @formatter:off + "spring.ai.openai-sdk.base-url=http://TEST.BASE.URL", + "spring.ai.openai-sdk.api-key=abc123", + "spring.ai.openai-sdk.embedding.base-url=http://TEST.BASE.URL2", + "spring.ai.openai-sdk.embedding.api-key=456", + "spring.ai.openai-sdk.embedding.options.model=MODEL_XYZ", + "spring.ai.openai-sdk.embedding.options.dimensions=512") + // @formatter:on + .withConfiguration(SpringAiTestAutoConfigurations.of(OpenAiSdkEmbeddingAutoConfiguration.class)) + .run(context -> { + var embeddingProperties = context.getBean(OpenAiSdkEmbeddingProperties.class); + var connectionProperties = context.getBean(OpenAiSdkConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("http://TEST.BASE.URL"); + + assertThat(embeddingProperties.getApiKey()).isEqualTo("456"); + assertThat(embeddingProperties.getBaseUrl()).isEqualTo("http://TEST.BASE.URL2"); + + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(embeddingProperties.getOptions().getDimensions()).isEqualTo(512); + }); + } + + @Test + public void embeddingOptionsTest() { + + this.contextRunner + .withPropertyValues(// @formatter:off + "spring.ai.openai-sdk.api-key=API_KEY", + "spring.ai.openai-sdk.base-url=http://TEST.BASE.URL", + + "spring.ai.openai-sdk.embedding.options.model=MODEL_XYZ", + "spring.ai.openai-sdk.embedding.options.user=userXYZ", + "spring.ai.openai-sdk.embedding.options.dimensions=1024", + "spring.ai.openai-sdk.embedding.metadata-mode=NONE" + ) + // @formatter:on + .withConfiguration(SpringAiTestAutoConfigurations.of(OpenAiSdkEmbeddingAutoConfiguration.class)) + .run(context -> { + var embeddingProperties = context.getBean(OpenAiSdkEmbeddingProperties.class); + var connectionProperties = context.getBean(OpenAiSdkConnectionProperties.class); + + assertThat(connectionProperties.getBaseUrl()).isEqualTo("http://TEST.BASE.URL"); + assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY"); + + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(embeddingProperties.getOptions().getUser()).isEqualTo("userXYZ"); + assertThat(embeddingProperties.getOptions().getDimensions()).isEqualTo(1024); + assertThat(embeddingProperties.getMetadataMode()).isEqualTo(MetadataMode.NONE); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/test/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkImageAutoConfigurationIT.java b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/test/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkImageAutoConfigurationIT.java new file mode 100644 index 00000000000..bfd6c426fc4 --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/test/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkImageAutoConfigurationIT.java @@ -0,0 +1,135 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.model.openaisdk.autoconfigure; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import org.springframework.ai.image.ImagePrompt; +import org.springframework.ai.image.ImageResponse; +import org.springframework.ai.openaisdk.OpenAiSdkImageModel; +import org.springframework.ai.utils.SpringAiTestAutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".*") +public class OpenAiSdkImageAutoConfigurationIT { + + private static final Log logger = LogFactory.getLog(OpenAiSdkImageAutoConfigurationIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai-sdk.apiKey=" + System.getenv("OPENAI_API_KEY")); + + @Test + void generateImage() { + this.contextRunner.withPropertyValues("spring.ai.openai-sdk.image.options.size=1024x1024") + .withConfiguration(SpringAiTestAutoConfigurations.of(OpenAiSdkImageAutoConfiguration.class)) + .run(context -> { + OpenAiSdkImageModel imageModel = context.getBean(OpenAiSdkImageModel.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-sdk.image.options.model=dall-e-2", + "spring.ai.openai-sdk.image.options.size=256x256") + .withConfiguration(SpringAiTestAutoConfigurations.of(OpenAiSdkImageAutoConfiguration.class)) + .run(context -> { + OpenAiSdkImageModel imageModel = context.getBean(OpenAiSdkImageModel.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 imageActivation() { + this.contextRunner + .withPropertyValues("spring.ai.openai-sdk.api-key=API_KEY", + "spring.ai.openai-sdk.base-url=http://TEST.BASE.URL", "spring.ai.model.image=none") + .withConfiguration(SpringAiTestAutoConfigurations.of(OpenAiSdkImageAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiSdkImageProperties.class)).isEmpty(); + assertThat(context.getBeansOfType(OpenAiSdkImageModel.class)).isEmpty(); + }); + + this.contextRunner + .withPropertyValues("spring.ai.openai-sdk.api-key=API_KEY", + "spring.ai.openai-sdk.base-url=http://TEST.BASE.URL") + .withConfiguration(SpringAiTestAutoConfigurations.of(OpenAiSdkImageAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiSdkImageProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiSdkImageModel.class)).isNotEmpty(); + }); + + this.contextRunner + .withPropertyValues("spring.ai.openai-sdk.api-key=API_KEY", + "spring.ai.openai-sdk.base-url=http://TEST.BASE.URL", "spring.ai.model.image=openai-sdk") + .withConfiguration(SpringAiTestAutoConfigurations.of(OpenAiSdkImageAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(OpenAiSdkImageProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(OpenAiSdkImageModel.class)).isNotEmpty(); + }); + + } + + @Test + public void imageOptionsTest() { + this.contextRunner.withPropertyValues( + // @formatter:off + "spring.ai.openai-sdk.api-key=API_KEY", + "spring.ai.openai-sdk.base-url=http://TEST.BASE.URL", + "spring.ai.openai-sdk.image.options.n=3", + "spring.ai.openai-sdk.image.options.model=MODEL_XYZ", + "spring.ai.openai-sdk.image.options.quality=hd", + "spring.ai.openai-sdk.image.options.response_format=url", + "spring.ai.openai-sdk.image.options.size=1024x1024", + "spring.ai.openai-sdk.image.options.width=1024", + "spring.ai.openai-sdk.image.options.height=1024", + "spring.ai.openai-sdk.image.options.style=vivid", + "spring.ai.openai-sdk.image.options.user=userXYZ") // @formatter:on + .withConfiguration(SpringAiTestAutoConfigurations.of(OpenAiSdkImageAutoConfiguration.class)) + .run(context -> { + var imageProperties = context.getBean(OpenAiSdkImageProperties.class); + var connectionProperties = context.getBean(OpenAiSdkConnectionProperties.class); + + assertThat(connectionProperties.getBaseUrl()).isEqualTo("http://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"); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/test/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkImagePropertiesTests.java b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/test/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkImagePropertiesTests.java new file mode 100644 index 00000000000..60ddf53fe32 --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk/src/test/java/org/springframework/ai/model/openaisdk/autoconfigure/OpenAiSdkImagePropertiesTests.java @@ -0,0 +1,128 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.model.openaisdk.autoconfigure; + +import org.junit.jupiter.api.Test; + +import org.springframework.ai.utils.SpringAiTestAutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit Tests for {@link OpenAiConnectionProperties} and {@link OpenAiSdkImageProperties}. + * + * @author Christian Tzolov + */ +public class OpenAiSdkImagePropertiesTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner(); + + @Test + public void imageProperties() { + + this.contextRunner.withPropertyValues( + // @formatter:off + "spring.ai.openai-sdk.base-url=http://TEST.BASE.URL", + "spring.ai.openai-sdk.api-key=abc123", + "spring.ai.openai-sdk.image.options.model=MODEL_XYZ", + "spring.ai.openai-sdk.image.options.n=2") + // @formatter:on + .withConfiguration(SpringAiTestAutoConfigurations.of(OpenAiSdkImageAutoConfiguration.class)) + .run(context -> { + var imageProperties = context.getBean(OpenAiSdkImageProperties.class); + var connectionProperties = context.getBean(OpenAiSdkConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("http://TEST.BASE.URL"); + + assertThat(imageProperties.getApiKey()).isNull(); + assertThat(imageProperties.getBaseUrl()).isNull(); + + assertThat(imageProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(imageProperties.getOptions().getN()).isEqualTo(2); + }); + } + + @Test + public void imageOverrideConnectionProperties() { + + this.contextRunner.withPropertyValues( + // @formatter:off + "spring.ai.openai-sdk.base-url=http://TEST.BASE.URL", + "spring.ai.openai-sdk.api-key=abc123", + "spring.ai.openai-sdk.image.base-url=http://TEST.BASE.URL2", + "spring.ai.openai-sdk.image.api-key=456", + "spring.ai.openai-sdk.image.options.model=MODEL_XYZ", + "spring.ai.openai-sdk.image.options.n=2") + // @formatter:on + .withConfiguration(SpringAiTestAutoConfigurations.of(OpenAiSdkImageAutoConfiguration.class)) + .run(context -> { + var imageProperties = context.getBean(OpenAiSdkImageProperties.class); + var connectionProperties = context.getBean(OpenAiSdkConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("http://TEST.BASE.URL"); + + assertThat(imageProperties.getApiKey()).isEqualTo("456"); + assertThat(imageProperties.getBaseUrl()).isEqualTo("http://TEST.BASE.URL2"); + + assertThat(imageProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(imageProperties.getOptions().getN()).isEqualTo(2); + }); + } + + @Test + public void imageOptionsTest() { + + this.contextRunner + .withPropertyValues(// @formatter:off + "spring.ai.openai-sdk.api-key=API_KEY", + "spring.ai.openai-sdk.base-url=http://TEST.BASE.URL", + + "spring.ai.openai-sdk.image.options.model=MODEL_XYZ", + "spring.ai.openai-sdk.image.options.n=3", + "spring.ai.openai-sdk.image.options.width=1024", + "spring.ai.openai-sdk.image.options.height=1792", + "spring.ai.openai-sdk.image.options.quality=hd", + "spring.ai.openai-sdk.image.options.responseFormat=url", + "spring.ai.openai-sdk.image.options.size=1024x1792", + "spring.ai.openai-sdk.image.options.style=vivid", + "spring.ai.openai-sdk.image.options.user=userXYZ" + ) + // @formatter:on + .withConfiguration(SpringAiTestAutoConfigurations.of(OpenAiSdkImageAutoConfiguration.class)) + .run(context -> { + var imageProperties = context.getBean(OpenAiSdkImageProperties.class); + var connectionProperties = context.getBean(OpenAiSdkConnectionProperties.class); + + assertThat(connectionProperties.getBaseUrl()).isEqualTo("http://TEST.BASE.URL"); + assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY"); + + assertThat(imageProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(imageProperties.getOptions().getN()).isEqualTo(3); + assertThat(imageProperties.getOptions().getWidth()).isEqualTo(1024); + assertThat(imageProperties.getOptions().getHeight()).isEqualTo(1792); + assertThat(imageProperties.getOptions().getQuality()).isEqualTo("hd"); + assertThat(imageProperties.getOptions().getResponseFormat()).isEqualTo("url"); + assertThat(imageProperties.getOptions().getSize()).isEqualTo("1024x1792"); + assertThat(imageProperties.getOptions().getStyle()).isEqualTo("vivid"); + assertThat(imageProperties.getOptions().getUser()).isEqualTo("userXYZ"); + }); + } + +} diff --git a/models/spring-ai-openai-sdk/README.md b/models/spring-ai-openai-sdk/README.md new file mode 100644 index 00000000000..66e5b980a12 --- /dev/null +++ b/models/spring-ai-openai-sdk/README.md @@ -0,0 +1,86 @@ +# OpenAI Java API Library + +This is the official OpenAI Java SDK from OpenAI, which provides integration with OpenAI's services, including Microsoft Foundry. + +[OpenAI Java API Library GitHub repository](https://github.com/openai/openai-java) + +## Authentication + +This module will try to automatically detect if you're using OpenAI, +Microsoft Foundry, or GitHub Models based on the provided base URL. + +Generic authentication is done using a URL and an API Key, such as: + +```java +OpenAiSdkChatOptions options = OpenAiSdkChatOptions.builder() + .baseUrl("https://.openai.microsoftFoundry.com/") + .apiKey("") + .build(); +``` + +Instead of providing the URL and API Key programmatically, you can also set them +using environment variables, using the keys below: + +```properties +OPENAI_BASE_URL=https://.openai.microsoftFoundry.com/ +OPENAI_API_KEY= +``` + +### Using OpenAI + +If you are using OpenAI, the base URL doesn't need to be set, as it's the default +`https://api.openai.com/v1` : + +```properties +OPENAI_BASE_URL=https://api.openai.com/v1 # Default value, can be omitted +OPENAI_API_KEY= +``` + +### Using Microsoft Foundry + +Microsoft Foundry will be automatically detected when using a Microsoft Foundry URL. +It can be forced if necessary by setting the `microsoftFoundry` configuration property to `true`. + +Here's an example using environment variables: + +```properties +OPENAI_BASE_URL=https://.openai.microsoftFoundry.com/ +OPENAI_API_KEY= +``` + +With Microsoft Foundry, you can also choose to use passwordless authentication, +without providing an API key. This is more secure, and is recommended approach +when running on Azure. + +To do so, you need to add the optional `com.azure:azure-identity` +dependency to your project. For example with Maven: + +```xml + + com.azure + azure-identity + +``` + +### Using GitHub Models + +GitHub Models will be automatically detected when using the GitHub Models base URL. +It can be forced if necessary by setting the `gitHubModels` configuration property to `true`. + +To authenticate, you'll need to create a GitHub Personal Access Token (PAT) with the `models:read` scope. + +Here's an example using environment variables: + +```properties +OPENAI_BASE_URL=https://models.github.ai/inference; +OPENAI_API_KEY=github_pat_XXXXXXXXXXX +``` + +## Logging + +As this module is built on top of the OpenAI Java SDK, you can enable logging +by setting the following environment variable: + +```properties +OPENAI_LOG=debug +``` diff --git a/models/spring-ai-openai-sdk/pom.xml b/models/spring-ai-openai-sdk/pom.xml new file mode 100644 index 00000000000..09d37bcc339 --- /dev/null +++ b/models/spring-ai-openai-sdk/pom.xml @@ -0,0 +1,95 @@ + + + + + 4.0.0 + + org.springframework.ai + spring-ai-parent + 2.0.0-SNAPSHOT + ../../pom.xml + + spring-ai-openai-sdk + jar + Spring AI Model - OpenAI SDK + OpenAI Java SDK support + https://github.com/spring-projects/spring-ai + + + https://github.com/spring-projects/spring-ai + git://github.com/spring-projects/spring-ai.git + git@github.com:spring-projects/spring-ai.git + + + + + + + + + + org.springframework.ai + spring-ai-model + ${project.parent.version} + + + + com.openai + openai-java + ${openai-sdk.version} + + + + com.azure + azure-identity + ${azure-identity.version} + true + + + + + org.springframework + spring-context-support + + + + org.slf4j + slf4j-api + + + + + org.springframework.ai + spring-ai-test + ${project.version} + test + + + + org.springframework.boot + spring-boot-starter-test + test + + + + io.micrometer + micrometer-observation-test + test + + + + diff --git a/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/AbstractOpenAiSdkOptions.java b/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/AbstractOpenAiSdkOptions.java new file mode 100644 index 00000000000..0b166a1298d --- /dev/null +++ b/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/AbstractOpenAiSdkOptions.java @@ -0,0 +1,215 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.openaisdk; + +import java.net.Proxy; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +import com.openai.azure.AzureOpenAIServiceVersion; +import com.openai.credential.Credential; + +public class AbstractOpenAiSdkOptions { + + /** + * The deployment URL to connect to OpenAI. + */ + private String baseUrl; + + /** + * The API key to connect to OpenAI. + */ + private String apiKey; + + /** + * Credentials used to connect to Microsoft Foundry. + */ + private Credential credential; + + /** + * The model name used. When using Microsoft Foundry, this is also used as the default + * deployment name. + */ + private String model; + + /** + * The deployment name as defined in Microsoft Foundry. On Microsoft Foundry, the + * default deployment name is the same as the model name. When using OpenAI directly, + * this value isn't used. + */ + private String microsoftDeploymentName; + + /** + * The Service version to use when connecting to Microsoft Foundry. + */ + private AzureOpenAIServiceVersion microsoftFoundryServiceVersion; + + /** + * The organization ID to use when connecting to Microsoft Foundry. + */ + private String organizationId; + + /** + * Whether Microsoft Foundry is detected. + */ + private boolean isMicrosoftFoundry; + + /** + * Whether GitHub Models is detected. + */ + private boolean isGitHubModels; + + /** + * Request timeout for OpenAI client. + */ + private Duration timeout; + + /** + * Maximum number of retries for OpenAI client. + */ + private Integer maxRetries; + + /** + * Proxy settings for OpenAI client. + */ + private Proxy proxy; + + /** + * Custom HTTP headers to add to OpenAI client requests. + */ + private Map customHeaders = new HashMap<>(); + + public String getBaseUrl() { + return this.baseUrl; + } + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + + public String getApiKey() { + return this.apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public Credential getCredential() { + return this.credential; + } + + public void setCredential(Credential credential) { + this.credential = credential; + } + + public String getModel() { + return this.model; + } + + public void setModel(String model) { + this.model = model; + } + + public String getMicrosoftDeploymentName() { + return this.microsoftDeploymentName; + } + + public void setMicrosoftDeploymentName(String microsoftDeploymentName) { + this.microsoftDeploymentName = microsoftDeploymentName; + } + + /** + * Alias for getAzureDeploymentName() + */ + public String getDeploymentName() { + return this.microsoftDeploymentName; + } + + /** + * Alias for setAzureDeploymentName() + */ + public void setDeploymentName(String azureDeploymentName) { + this.microsoftDeploymentName = azureDeploymentName; + } + + public AzureOpenAIServiceVersion getMicrosoftFoundryServiceVersion() { + return this.microsoftFoundryServiceVersion; + } + + public void setMicrosoftFoundryServiceVersion(AzureOpenAIServiceVersion microsoftFoundryServiceVersion) { + this.microsoftFoundryServiceVersion = microsoftFoundryServiceVersion; + } + + public String getOrganizationId() { + return this.organizationId; + } + + public void setOrganizationId(String organizationId) { + this.organizationId = organizationId; + } + + public boolean isMicrosoftFoundry() { + return this.isMicrosoftFoundry; + } + + public void setMicrosoftFoundry(boolean microsoftFoundry) { + this.isMicrosoftFoundry = microsoftFoundry; + } + + public boolean isGitHubModels() { + return this.isGitHubModels; + } + + public void setGitHubModels(boolean gitHubModels) { + this.isGitHubModels = gitHubModels; + } + + public Duration getTimeout() { + return this.timeout; + } + + public void setTimeout(Duration timeout) { + this.timeout = timeout; + } + + public Integer getMaxRetries() { + return this.maxRetries; + } + + public void setMaxRetries(Integer maxRetries) { + this.maxRetries = maxRetries; + } + + public Proxy getProxy() { + return this.proxy; + } + + public void setProxy(Proxy proxy) { + this.proxy = proxy; + } + + public Map getCustomHeaders() { + return this.customHeaders; + } + + public void setCustomHeaders(Map customHeaders) { + this.customHeaders = customHeaders; + } + +} diff --git a/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/OpenAiSdkChatModel.java b/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/OpenAiSdkChatModel.java new file mode 100644 index 00000000000..46db7378862 --- /dev/null +++ b/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/OpenAiSdkChatModel.java @@ -0,0 +1,1321 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.openaisdk; + +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.databind.JsonNode; +import com.openai.client.OpenAIClient; +import com.openai.client.OpenAIClientAsync; +import com.openai.core.JsonValue; +import com.openai.models.FunctionDefinition; +import com.openai.models.FunctionParameters; +import com.openai.models.ReasoningEffort; +import com.openai.models.ResponseFormatJsonObject; +import com.openai.models.ResponseFormatJsonSchema; +import com.openai.models.ResponseFormatText; +import com.openai.models.chat.completions.ChatCompletion; +import com.openai.models.chat.completions.ChatCompletionAssistantMessageParam; +import com.openai.models.chat.completions.ChatCompletionChunk; +import com.openai.models.chat.completions.ChatCompletionContentPart; +import com.openai.models.chat.completions.ChatCompletionContentPartImage; +import com.openai.models.chat.completions.ChatCompletionContentPartInputAudio; +import com.openai.models.chat.completions.ChatCompletionContentPartText; +import com.openai.models.chat.completions.ChatCompletionCreateParams; +import com.openai.models.chat.completions.ChatCompletionFunctionTool; +import com.openai.models.chat.completions.ChatCompletionMessage; +import com.openai.models.chat.completions.ChatCompletionMessageFunctionToolCall; +import com.openai.models.chat.completions.ChatCompletionMessageParam; +import com.openai.models.chat.completions.ChatCompletionMessageToolCall; +import com.openai.models.chat.completions.ChatCompletionNamedToolChoice; +import com.openai.models.chat.completions.ChatCompletionStreamOptions; +import com.openai.models.chat.completions.ChatCompletionTool; +import com.openai.models.chat.completions.ChatCompletionToolChoiceOption; +import com.openai.models.chat.completions.ChatCompletionToolMessageParam; +import com.openai.models.chat.completions.ChatCompletionUserMessageParam; +import com.openai.models.completions.CompletionUsage; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; +import io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; +import reactor.core.scheduler.Schedulers; + +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.MessageType; +import org.springframework.ai.chat.messages.ToolResponseMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.metadata.ChatGenerationMetadata; +import org.springframework.ai.chat.metadata.ChatResponseMetadata; +import org.springframework.ai.chat.metadata.DefaultUsage; +import org.springframework.ai.chat.metadata.EmptyUsage; +import org.springframework.ai.chat.metadata.Usage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.observation.ChatModelObservationContext; +import org.springframework.ai.chat.observation.ChatModelObservationConvention; +import org.springframework.ai.chat.observation.ChatModelObservationDocumentation; +import org.springframework.ai.chat.observation.DefaultChatModelObservationConvention; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.content.Media; +import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.model.tool.DefaultToolExecutionEligibilityPredicate; +import org.springframework.ai.model.tool.ToolCallingChatOptions; +import org.springframework.ai.model.tool.ToolCallingManager; +import org.springframework.ai.model.tool.ToolExecutionEligibilityPredicate; +import org.springframework.ai.model.tool.ToolExecutionResult; +import org.springframework.ai.model.tool.internal.ToolCallReactiveContextHolder; +import org.springframework.ai.observation.conventions.AiProvider; +import org.springframework.ai.openaisdk.setup.OpenAiSdkSetup; +import org.springframework.ai.support.UsageCalculator; +import org.springframework.ai.tool.definition.ToolDefinition; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.MimeTypeUtils; +import org.springframework.util.StringUtils; + +/** + * Chat Model implementation using the OpenAI Java SDK. + * + * @author Julien Dubois + * @author Christian Tzolov + */ +public class OpenAiSdkChatModel implements ChatModel { + + private static final String DEFAULT_MODEL_NAME = OpenAiSdkChatOptions.DEFAULT_CHAT_MODEL; + + private static final ChatModelObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultChatModelObservationConvention(); + + private static final ToolCallingManager DEFAULT_TOOL_CALLING_MANAGER = ToolCallingManager.builder().build(); + + private final Logger logger = LoggerFactory.getLogger(OpenAiSdkChatModel.class); + + private final OpenAIClient openAiClient; + + private final OpenAIClientAsync openAiClientAsync; + + private final OpenAiSdkChatOptions options; + + private final ObservationRegistry observationRegistry; + + private final ToolCallingManager toolCallingManager; + + private final ToolExecutionEligibilityPredicate toolExecutionEligibilityPredicate; + + private ChatModelObservationConvention observationConvention = DEFAULT_OBSERVATION_CONVENTION; + + /** + * Creates a new OpenAiSdkChatModel with default options. + */ + public OpenAiSdkChatModel() { + this(null, null, null, null, null, null); + } + + /** + * Creates a new OpenAiSdkChatModel with the given options. + * @param options the chat options + */ + public OpenAiSdkChatModel(OpenAiSdkChatOptions options) { + this(null, null, options, null, null, null); + } + + /** + * Creates a new OpenAiSdkChatModel with the given options and observation registry. + * @param options the chat options + * @param observationRegistry the observation registry + */ + public OpenAiSdkChatModel(OpenAiSdkChatOptions options, ObservationRegistry observationRegistry) { + this(null, null, options, null, observationRegistry, null); + } + + /** + * Creates a new OpenAiSdkChatModel with the given options, tool calling manager, and + * observation registry. + * @param options the chat options + * @param toolCallingManager the tool calling manager + * @param observationRegistry the observation registry + */ + public OpenAiSdkChatModel(OpenAiSdkChatOptions options, ToolCallingManager toolCallingManager, + ObservationRegistry observationRegistry) { + this(null, null, options, toolCallingManager, observationRegistry, null); + } + + /** + * Creates a new OpenAiSdkChatModel with the given OpenAI clients. + * @param openAIClient the synchronous OpenAI client + * @param openAiClientAsync the asynchronous OpenAI client + */ + public OpenAiSdkChatModel(OpenAIClient openAIClient, OpenAIClientAsync openAiClientAsync) { + this(openAIClient, openAiClientAsync, null, null, null, null); + } + + /** + * Creates a new OpenAiSdkChatModel with the given OpenAI clients and options. + * @param openAIClient the synchronous OpenAI client + * @param openAiClientAsync the asynchronous OpenAI client + * @param options the chat options + */ + public OpenAiSdkChatModel(OpenAIClient openAIClient, OpenAIClientAsync openAiClientAsync, + OpenAiSdkChatOptions options) { + this(openAIClient, openAiClientAsync, options, null, null, null); + } + + /** + * Creates a new OpenAiSdkChatModel with the given OpenAI clients, options, and + * observation registry. + * @param openAIClient the synchronous OpenAI client + * @param openAiClientAsync the asynchronous OpenAI client + * @param options the chat options + * @param observationRegistry the observation registry + */ + public OpenAiSdkChatModel(OpenAIClient openAIClient, OpenAIClientAsync openAiClientAsync, + OpenAiSdkChatOptions options, ObservationRegistry observationRegistry) { + this(openAIClient, openAiClientAsync, options, null, observationRegistry, null); + } + + /** + * Creates a new OpenAiSdkChatModel with all configuration options. + * @param openAiClient the synchronous OpenAI client + * @param openAiClientAsync the asynchronous OpenAI client + * @param options the chat options + * @param toolCallingManager the tool calling manager + * @param observationRegistry the observation registry + * @param toolExecutionEligibilityPredicate the predicate to determine tool execution + * eligibility + */ + public OpenAiSdkChatModel(OpenAIClient openAiClient, OpenAIClientAsync openAiClientAsync, + OpenAiSdkChatOptions options, ToolCallingManager toolCallingManager, + ObservationRegistry observationRegistry, + ToolExecutionEligibilityPredicate toolExecutionEligibilityPredicate) { + + if (options == null) { + this.options = OpenAiSdkChatOptions.builder().model(DEFAULT_MODEL_NAME).build(); + } + else { + this.options = options; + } + this.openAiClient = Objects.requireNonNullElseGet(openAiClient, + () -> OpenAiSdkSetup.setupSyncClient(this.options.getBaseUrl(), this.options.getApiKey(), + this.options.getCredential(), this.options.getMicrosoftDeploymentName(), + this.options.getMicrosoftFoundryServiceVersion(), this.options.getOrganizationId(), + this.options.isMicrosoftFoundry(), this.options.isGitHubModels(), this.options.getModel(), + this.options.getTimeout(), this.options.getMaxRetries(), this.options.getProxy(), + this.options.getCustomHeaders())); + + this.openAiClientAsync = Objects.requireNonNullElseGet(openAiClientAsync, + () -> OpenAiSdkSetup.setupAsyncClient(this.options.getBaseUrl(), this.options.getApiKey(), + this.options.getCredential(), this.options.getMicrosoftDeploymentName(), + this.options.getMicrosoftFoundryServiceVersion(), this.options.getOrganizationId(), + this.options.isMicrosoftFoundry(), this.options.isGitHubModels(), this.options.getModel(), + this.options.getTimeout(), this.options.getMaxRetries(), this.options.getProxy(), + this.options.getCustomHeaders())); + + this.observationRegistry = Objects.requireNonNullElse(observationRegistry, ObservationRegistry.NOOP); + this.toolCallingManager = Objects.requireNonNullElse(toolCallingManager, DEFAULT_TOOL_CALLING_MANAGER); + this.toolExecutionEligibilityPredicate = Objects.requireNonNullElse(toolExecutionEligibilityPredicate, + new DefaultToolExecutionEligibilityPredicate()); + } + + /** + * Gets the chat options for this model. + * @return the chat options + */ + public OpenAiSdkChatOptions getOptions() { + return this.options; + } + + @Override + public ChatResponse call(Prompt prompt) { + if (this.openAiClient == null) { + throw new IllegalStateException( + "OpenAI sync client is not configured. Have you set the 'streamUsage' option to false?"); + } + Prompt requestPrompt = buildRequestPrompt(prompt); + return this.internalCall(requestPrompt, null); + } + + /** + * Internal method to handle chat completion calls with tool execution support. + * @param prompt the prompt for the chat completion + * @param previousChatResponse the previous chat response for accumulating usage + * @return the chat response + */ + public ChatResponse internalCall(Prompt prompt, ChatResponse previousChatResponse) { + + ChatCompletionCreateParams request = createRequest(prompt, false); + + ChatModelObservationContext observationContext = ChatModelObservationContext.builder() + .prompt(prompt) + .provider(AiProvider.OPENAI_SDK.value()) + .build(); + + ChatResponse response = ChatModelObservationDocumentation.CHAT_MODEL_OPERATION + .observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, + this.observationRegistry) + .observe(() -> { + + ChatCompletion chatCompletion = this.openAiClient.chat().completions().create(request); + + List choices = chatCompletion.choices(); + if (choices.isEmpty()) { + logger.warn("No choices returned for prompt: {}", prompt); + return new ChatResponse(List.of()); + } + + List generations = choices.stream().map(choice -> { + chatCompletion.id(); + choice.finishReason(); + Map metadata = Map.of("id", chatCompletion.id(), "role", + choice.message()._role().asString().isPresent() ? choice.message()._role().asStringOrThrow() + : "", + "index", choice.index(), "finishReason", choice.finishReason().value().toString(), + "refusal", choice.message().refusal().isPresent() ? choice.message().refusal() : "", + "annotations", choice.message().annotations().isPresent() ? choice.message().annotations() + : List.of(Map.of())); + return buildGeneration(choice, metadata, request); + }).toList(); + + // Current usage + CompletionUsage usage = chatCompletion.usage().orElse(null); + Usage currentChatResponseUsage = usage != null ? getDefaultUsage(usage) : new EmptyUsage(); + Usage accumulatedUsage = UsageCalculator.getCumulativeUsage(currentChatResponseUsage, + previousChatResponse); + ChatResponse chatResponse = new ChatResponse(generations, from(chatCompletion, accumulatedUsage)); + + observationContext.setResponse(chatResponse); + + return chatResponse; + + }); + + if (this.toolExecutionEligibilityPredicate.isToolExecutionRequired(prompt.getOptions(), response)) { + var toolExecutionResult = this.toolCallingManager.executeToolCalls(prompt, response); + if (toolExecutionResult.returnDirect()) { + // Return tool execution result directly to the client. + return ChatResponse.builder() + .from(response) + .generations(ToolExecutionResult.buildGenerations(toolExecutionResult)) + .build(); + } + else { + // Send the tool execution result back to the model. + return this.internalCall(new Prompt(toolExecutionResult.conversationHistory(), prompt.getOptions()), + response); + } + } + + return response; + } + + @Override + public Flux stream(Prompt prompt) { + if (this.openAiClientAsync == null) { + throw new IllegalStateException( + "OpenAI async client is not configured. Streaming is not supported with the current configuration. Have you set the 'streamUsage' option to true?"); + } + Prompt requestPrompt = buildRequestPrompt(prompt); + return internalStream(requestPrompt, null); + } + + /** + * Safely extracts the assistant message from a chat response. + * @param response the chat response + * @return the assistant message, or null if not available + */ + public AssistantMessage safeAssistantMessage(ChatResponse response) { + if (response == null) { + return null; + } + Generation gen = response.getResult(); + if (gen == null) { + return null; + } + return gen.getOutput(); + } + + /** + * Internal method to handle streaming chat completion calls with tool execution + * support. + * @param prompt the prompt for the chat completion + * @param previousChatResponse the previous chat response for accumulating usage + * @return a Flux of chat responses + */ + public Flux internalStream(Prompt prompt, ChatResponse previousChatResponse) { + return Flux.deferContextual(contextView -> { + ChatCompletionCreateParams request = createRequest(prompt, true); + ConcurrentHashMap roleMap = new ConcurrentHashMap<>(); + final ChatModelObservationContext observationContext = ChatModelObservationContext.builder() + .prompt(prompt) + .provider(AiProvider.OPENAI_SDK.value()) + .build(); + Observation observation = ChatModelObservationDocumentation.CHAT_MODEL_OPERATION.observation( + this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, + this.observationRegistry); + observation.parentObservation(contextView.getOrDefault(ObservationThreadLocalAccessor.KEY, null)).start(); + + Flux chatResponses = Flux.create(sink -> { + this.openAiClientAsync.chat().completions().createStreaming(request).subscribe(chunk -> { + try { + ChatCompletion chatCompletion = chunkToChatCompletion(chunk); + String id = chatCompletion.id(); + List generations = chatCompletion.choices().stream().map(choice -> { + roleMap.putIfAbsent(id, choice.message()._role().asString().isPresent() + ? choice.message()._role().asStringOrThrow() : ""); + + Map metadata = Map.of("id", id, "role", roleMap.getOrDefault(id, ""), + "index", choice.index(), "finishReason", choice.finishReason().value(), "refusal", + choice.message().refusal().isPresent() ? choice.message().refusal() : "", + "annotations", choice.message().annotations().isPresent() + ? choice.message().annotations() : List.of(), + "chunkChoice", chunk.choices().get((int) choice.index())); + + return buildGeneration(choice, metadata, request); + }).toList(); + Optional usage = chatCompletion.usage(); + CompletionUsage usageVal = usage.orElse(null); + Usage currentUsage = usageVal != null ? getDefaultUsage(usageVal) : new EmptyUsage(); + Usage accumulated = UsageCalculator.getCumulativeUsage(currentUsage, previousChatResponse); + sink.next(new ChatResponse(generations, from(chatCompletion, accumulated))); + } + catch (Exception e) { + logger.error("Error processing chat completion", e); + sink.error(e); + } + }).onCompleteFuture().whenComplete((unused, throwable) -> { + if (throwable != null) { + sink.error(throwable); + } + else { + sink.complete(); + } + }); + }).buffer(2, 1).map(buffer -> { + ChatResponse first = buffer.get(0); + if (request.streamOptions().isPresent() && buffer.size() == 2) { + ChatResponse second = buffer.get(1); + if (second != null) { + Usage usage = second.getMetadata().getUsage(); + if (!UsageCalculator.isEmpty(usage)) { + return new ChatResponse(first.getResults(), from(first.getMetadata(), usage)); + } + } + } + return first; + }); + + Flux flux = chatResponses + .contextWrite(ctx -> ctx.put(ObservationThreadLocalAccessor.KEY, observation)); + + return flux.collectList().flatMapMany(list -> { + if (list.isEmpty()) { + return Flux.empty(); + } + boolean hasToolCalls = list.stream() + .map(this::safeAssistantMessage) + .filter(Objects::nonNull) + .anyMatch(am -> !CollectionUtils.isEmpty(am.getToolCalls())); + if (!hasToolCalls) { + if (list.size() > 2) { + ChatResponse penultimateResponse = list.get(list.size() - 2); // Get + // the + // finish + // reason + ChatResponse lastResponse = list.get(list.size() - 1); // Get the + // usage + Usage usage = lastResponse.getMetadata().getUsage(); + observationContext.setResponse(new ChatResponse(penultimateResponse.getResults(), + from(penultimateResponse.getMetadata(), usage))); + } + return Flux.fromIterable(list); + } + Map builders = new HashMap<>(); + StringBuilder text = new StringBuilder(); + ChatResponseMetadata finalMetadata = null; + ChatGenerationMetadata finalGenMetadata = null; + Map props = new HashMap<>(); + for (ChatResponse chatResponse : list) { + AssistantMessage am = safeAssistantMessage(chatResponse); + if (am == null) { + continue; + } + if (am.getText() != null) { + text.append(am.getText()); + } + if (am.getMetadata() != null) { + props.putAll(am.getMetadata()); + } + if (!CollectionUtils.isEmpty(am.getToolCalls())) { + Object ccObj = am.getMetadata().get("chunkChoice"); + if (ccObj instanceof ChatCompletionChunk.Choice chunkChoice + && chunkChoice.delta().toolCalls().isPresent()) { + List deltaCalls = chunkChoice.delta() + .toolCalls() + .get(); + for (int i = 0; i < am.getToolCalls().size() && i < deltaCalls.size(); i++) { + AssistantMessage.ToolCall tc = am.getToolCalls().get(i); + ChatCompletionChunk.Choice.Delta.ToolCall dtc = deltaCalls.get(i); + String key = chunkChoice.index() + "-" + dtc.index(); + ToolCallBuilder toolCallBuilder = builders.computeIfAbsent(key, + k -> new ToolCallBuilder()); + toolCallBuilder.merge(tc); + } + } + else { + for (AssistantMessage.ToolCall tc : am.getToolCalls()) { + ToolCallBuilder toolCallBuilder = builders.computeIfAbsent(tc.id(), + k -> new ToolCallBuilder()); + toolCallBuilder.merge(tc); + } + } + } + Generation generation = chatResponse.getResult(); + if (generation != null && generation.getMetadata() != null + && generation.getMetadata() != ChatGenerationMetadata.NULL) { + finalGenMetadata = generation.getMetadata(); + } + if (chatResponse.getMetadata() != null) { + finalMetadata = chatResponse.getMetadata(); + } + } + List merged = builders.values() + .stream() + .map(ToolCallBuilder::build) + .filter(tc -> StringUtils.hasText(tc.name())) + .toList(); + AssistantMessage.Builder assistantMessageBuilder = AssistantMessage.builder() + .content(text.toString()) + .properties(props); + if (!merged.isEmpty()) { + assistantMessageBuilder.toolCalls(merged); + } + AssistantMessage assistantMessage = assistantMessageBuilder.build(); + Generation finalGen = new Generation(assistantMessage, + finalGenMetadata != null ? finalGenMetadata : ChatGenerationMetadata.NULL); + ChatResponse aggregated = new ChatResponse(List.of(finalGen), finalMetadata); + observationContext.setResponse(aggregated); + if (this.toolExecutionEligibilityPredicate.isToolExecutionRequired(prompt.getOptions(), aggregated)) { + return Flux.deferContextual(ctx -> { + ToolExecutionResult tetoolExecutionResult; + try { + ToolCallReactiveContextHolder.setContext(ctx); + tetoolExecutionResult = this.toolCallingManager.executeToolCalls(prompt, aggregated); + } + finally { + ToolCallReactiveContextHolder.clearContext(); + } + if (tetoolExecutionResult.returnDirect()) { + return Flux.just(ChatResponse.builder() + .from(aggregated) + .generations(ToolExecutionResult.buildGenerations(tetoolExecutionResult)) + .build()); + } + return this.internalStream( + new Prompt(tetoolExecutionResult.conversationHistory(), prompt.getOptions()), + aggregated); + }).subscribeOn(Schedulers.boundedElastic()); + } + return Flux.just(aggregated); + }).doOnError(observation::error).doFinally(s -> observation.stop()); + }); + } + + private Generation buildGeneration(ChatCompletion.Choice choice, Map metadata, + ChatCompletionCreateParams request) { + ChatCompletionMessage message = choice.message(); + List toolCalls = new ArrayList<>(); + + if (metadata.containsKey("chunkChoice")) { + Object chunkChoiceObj = metadata.get("chunkChoice"); + if (chunkChoiceObj instanceof ChatCompletionChunk.Choice chunkChoice) { + if (chunkChoice.delta().toolCalls().isPresent()) { + toolCalls = chunkChoice.delta() + .toolCalls() + .get() + .stream() + .filter(tc -> tc.function().isPresent()) + .map(tc -> { + var funcOpt = tc.function(); + if (funcOpt.isEmpty()) { + return null; + } + var func = funcOpt.get(); + String id = tc.id().orElse(""); + String name = func.name().orElse(""); + String arguments = func.arguments().orElse(""); + return new AssistantMessage.ToolCall(id, "function", name, arguments); + }) + .filter(Objects::nonNull) + .toList(); + } + } + } + else { + toolCalls = message.toolCalls() + .map(list -> list.stream().filter(tc -> tc.function().isPresent()).map(tc -> { + var opt = tc.function(); + if (opt.isEmpty()) { + return null; + } + var funcCall = opt.get(); + var functionDef = funcCall.function(); + String id = funcCall.id(); + String name = functionDef.name(); + String arguments = functionDef.arguments(); + return new AssistantMessage.ToolCall(id, "function", name, arguments); + }).filter(Objects::nonNull).toList()) + .orElse(List.of()); + } + + var generationMetadataBuilder = ChatGenerationMetadata.builder() + .finishReason(choice.finishReason().value().name()); + + String textContent = message.content().orElse(""); + + List media = new ArrayList<>(); + + if (message.audio().isPresent() && StringUtils.hasText(message.audio().get().data()) + && request.audio().isPresent()) { + var audioOutput = message.audio().get(); + String mimeType = String.format("audio/%s", request.audio().get().format().value().name().toLowerCase()); + byte[] audioData = Base64.getDecoder().decode(audioOutput.data()); + Resource resource = new ByteArrayResource(audioData); + Media.builder().mimeType(MimeTypeUtils.parseMimeType(mimeType)).data(resource).id(audioOutput.id()).build(); + media.add(Media.builder() + .mimeType(MimeTypeUtils.parseMimeType(mimeType)) + .data(resource) + .id(audioOutput.id()) + .build()); + if (!StringUtils.hasText(textContent)) { + textContent = audioOutput.transcript(); + } + generationMetadataBuilder.metadata("audioId", audioOutput.id()); + generationMetadataBuilder.metadata("audioExpiresAt", audioOutput.expiresAt()); + } + + var assistantMessage = AssistantMessage.builder() + .content(textContent) + .properties(metadata) + .toolCalls(toolCalls) + .media(media) + .build(); + return new Generation(assistantMessage, generationMetadataBuilder.build()); + } + + private ChatResponseMetadata from(ChatCompletion result, Usage usage) { + Assert.notNull(result, "OpenAI ChatCompletion must not be null"); + result.model(); + result.id(); + return ChatResponseMetadata.builder() + .id(result.id()) + .usage(usage) + .model(result.model()) + .keyValue("created", result.created()) + .build(); + } + + private ChatResponseMetadata from(ChatResponseMetadata chatResponseMetadata, Usage usage) { + Assert.notNull(chatResponseMetadata, "OpenAI ChatResponseMetadata must not be null"); + return ChatResponseMetadata.builder() + .id(chatResponseMetadata.getId() != null ? chatResponseMetadata.getId() : "") + .usage(usage) + .model(chatResponseMetadata.getModel() != null ? chatResponseMetadata.getModel() : "") + .build(); + } + + /** + * Convert the ChatCompletionChunk into a ChatCompletion. The Usage is set to null. + * @param chunk the ChatCompletionChunk to convert + * @return the ChatCompletion + */ + private ChatCompletion chunkToChatCompletion(ChatCompletionChunk chunk) { + + List choices = (chunk._choices().isMissing()) ? List.of() + : chunk.choices().stream().map(chunkChoice -> { + ChatCompletion.Choice.FinishReason finishReason = ChatCompletion.Choice.FinishReason.of(""); + if (chunkChoice.finishReason().isPresent()) { + finishReason = ChatCompletion.Choice.FinishReason + .of(chunkChoice.finishReason().get().value().name().toLowerCase()); + } + + ChatCompletion.Choice.Builder choiceBuilder = ChatCompletion.Choice.builder() + .finishReason(finishReason) + .index(chunkChoice.index()) + .message(ChatCompletionMessage.builder() + .content(chunkChoice.delta().content()) + .refusal(chunkChoice.delta().refusal()) + .build()); + + // Handle optional logprobs + if (chunkChoice.logprobs().isPresent()) { + var logprobs = chunkChoice.logprobs().get(); + choiceBuilder.logprobs(ChatCompletion.Choice.Logprobs.builder() + .content(logprobs.content()) + .refusal(logprobs.refusal()) + .build()); + } + else { + // Provide empty logprobs when not present + choiceBuilder.logprobs( + ChatCompletion.Choice.Logprobs.builder().content(List.of()).refusal(List.of()).build()); + } + + chunkChoice.delta(); + + return choiceBuilder.build(); + }).toList(); + + return ChatCompletion.builder() + .id(chunk.id()) + .choices(choices) + .created(chunk.created()) + .model(chunk.model()) + .usage(chunk.usage() + .orElse(CompletionUsage.builder().promptTokens(0).completionTokens(0).totalTokens(0).build())) + .build(); + } + + private DefaultUsage getDefaultUsage(CompletionUsage usage) { + return new DefaultUsage(Math.toIntExact(usage.promptTokens()), Math.toIntExact(usage.completionTokens()), + Math.toIntExact(usage.totalTokens()), usage); + } + + /** + * Builds the request prompt by merging runtime options with default options. + * @param prompt the original prompt + * @return the prompt with merged options + */ + Prompt buildRequestPrompt(Prompt prompt) { + // Process runtime options + OpenAiSdkChatOptions runtimeOptions = null; + if (prompt.getOptions() != null) { + if (prompt.getOptions() instanceof ToolCallingChatOptions toolCallingChatOptions) { + runtimeOptions = ModelOptionsUtils.copyToTarget(toolCallingChatOptions, ToolCallingChatOptions.class, + OpenAiSdkChatOptions.class); + } + else { + runtimeOptions = ModelOptionsUtils.copyToTarget(prompt.getOptions(), ChatOptions.class, + OpenAiSdkChatOptions.class); + } + } + + // Define request options by merging runtime options and default options + OpenAiSdkChatOptions requestOptions = OpenAiSdkChatOptions.builder() + .from(this.options) + .merge(runtimeOptions != null ? runtimeOptions : OpenAiSdkChatOptions.builder().build()) + .build(); + + // Merge @JsonIgnore-annotated options explicitly since they are ignored by + // Jackson, used by ModelOptionsUtils. + if (runtimeOptions != null) { + if (runtimeOptions.getTopK() != null) { + logger.warn("The topK option is not supported by OpenAI chat models. Ignoring."); + } + + requestOptions.setInternalToolExecutionEnabled(runtimeOptions.getInternalToolExecutionEnabled() != null + ? runtimeOptions.getInternalToolExecutionEnabled() + : this.options.getInternalToolExecutionEnabled()); + requestOptions.setToolNames( + ToolCallingChatOptions.mergeToolNames(runtimeOptions.getToolNames(), this.options.getToolNames())); + requestOptions.setToolCallbacks(ToolCallingChatOptions.mergeToolCallbacks(runtimeOptions.getToolCallbacks(), + this.options.getToolCallbacks())); + requestOptions.setToolContext(ToolCallingChatOptions.mergeToolContext(runtimeOptions.getToolContext(), + this.options.getToolContext())); + } + else { + requestOptions.setInternalToolExecutionEnabled(this.options.getInternalToolExecutionEnabled()); + requestOptions.setToolNames(this.options.getToolNames()); + requestOptions.setToolCallbacks(this.options.getToolCallbacks()); + requestOptions.setToolContext(this.options.getToolContext()); + } + + ToolCallingChatOptions.validateToolCallbacks(requestOptions.getToolCallbacks()); + + return new Prompt(prompt.getInstructions(), requestOptions); + } + + /** + * Creates a chat completion request from the given prompt. + * @param prompt the prompt containing messages and options + * @param stream whether this is a streaming request + * @return the chat completion create parameters + */ + ChatCompletionCreateParams createRequest(Prompt prompt, boolean stream) { + + List chatCompletionMessageParams = prompt.getInstructions() + .stream() + .map(message -> { + if (message.getMessageType() == MessageType.USER || message.getMessageType() == MessageType.SYSTEM) { + // Handle simple text content for user and system messages + ChatCompletionUserMessageParam.Builder builder = ChatCompletionUserMessageParam.builder(); + + if (message instanceof UserMessage userMessage + && !CollectionUtils.isEmpty(userMessage.getMedia())) { + // Handle media content (images, audio, files) + List parts = new ArrayList<>(); + + if (!message.getText().isEmpty()) { + parts.add(ChatCompletionContentPart + .ofText(ChatCompletionContentPartText.builder().text(message.getText()).build())); + } + + // Add media content parts + userMessage.getMedia().forEach(media -> { + String mimeType = media.getMimeType().toString(); + if (mimeType.startsWith("image/")) { + if (media.getData() instanceof java.net.URI uri) { + parts.add(ChatCompletionContentPart + .ofImageUrl(ChatCompletionContentPartImage.builder() + .imageUrl(ChatCompletionContentPartImage.ImageUrl.builder() + .url(uri.toString()) + .build()) + .build())); + } + else if (media.getData() instanceof String text) { + // The org.springframework.ai.content.Media object + // should store the URL as a java.net.URI but it + // transforms it to String somewhere along the way, + // for example in its Builder class. So, we accept + // String as well here for image URLs. + parts.add(ChatCompletionContentPart + .ofImageUrl(ChatCompletionContentPartImage.builder() + .imageUrl( + ChatCompletionContentPartImage.ImageUrl.builder().url(text).build()) + .build())); + } + else if (media.getData() instanceof byte[] bytes) { + // Assume the bytes are an image. So, convert the + // bytes to a base64 encoded + ChatCompletionContentPartImage.ImageUrl.Builder imageUrlBuilder = ChatCompletionContentPartImage.ImageUrl + .builder(); + + imageUrlBuilder.url("data:" + mimeType + ";base64," + + Base64.getEncoder().encodeToString(bytes)); + parts.add(ChatCompletionContentPart + .ofImageUrl(ChatCompletionContentPartImage.builder() + .imageUrl(imageUrlBuilder.build()) + .build())); + } + else { + logger.info( + "Could not process image media with data of type: {}. Only java.net.URI is supported for image URLs.", + media.getData().getClass().getSimpleName()); + } + } + else if (mimeType.startsWith("audio/")) { + parts.add(ChatCompletionContentPart + .ofInputAudio(ChatCompletionContentPartInputAudio.builder() + .inputAudio(ChatCompletionContentPartInputAudio.builder() + .inputAudio(ChatCompletionContentPartInputAudio.InputAudio.builder() + .data(fromAudioData(media.getData())) + .format(mimeType.contains("mp3") + ? ChatCompletionContentPartInputAudio.InputAudio.Format.MP3 + : ChatCompletionContentPartInputAudio.InputAudio.Format.WAV) + .build()) + .build() + .inputAudio()) + .build())); + } + else { + // Assume it's a file or other media type represented as a + // data URL + parts.add(ChatCompletionContentPart.ofText(ChatCompletionContentPartText.builder() + .text(fromMediaData(media.getMimeType(), media.getData())) + .build())); + } + }); + builder.contentOfArrayOfContentParts(parts); + } + else { + // Simple text message + builder.content(ChatCompletionContentPartText.builder().text(message.getText()).build().text()); + } + + if (message.getMessageType() == MessageType.USER) { + builder.role(JsonValue.from(MessageType.USER.getValue())); + } + else { + builder.role(JsonValue.from(MessageType.SYSTEM.getValue())); + } + + return List.of(ChatCompletionMessageParam.ofUser(builder.build())); + } + else if (message.getMessageType() == MessageType.ASSISTANT) { + var assistantMessage = (AssistantMessage) message; + ChatCompletionAssistantMessageParam.Builder builder = ChatCompletionAssistantMessageParam.builder() + .role(JsonValue.from(MessageType.ASSISTANT.getValue())); + + if (assistantMessage.getText() != null) { + builder.content(ChatCompletionAssistantMessageParam.builder() + .content(assistantMessage.getText()) + .build() + .content()); + } + + if (!CollectionUtils.isEmpty(assistantMessage.getToolCalls())) { + List toolCalls = assistantMessage.getToolCalls() + .stream() + .map(toolCall -> ChatCompletionMessageToolCall + .ofFunction(ChatCompletionMessageFunctionToolCall.builder() + .id(toolCall.id()) + .function(ChatCompletionMessageFunctionToolCall.Function.builder() + .name(toolCall.name()) + .arguments(toolCall.arguments()) + .build()) + .build())) + .toList(); + + builder.toolCalls(toolCalls); + } + + return List.of(ChatCompletionMessageParam.ofAssistant(builder.build())); + } + else if (message.getMessageType() == MessageType.TOOL) { + ToolResponseMessage toolMessage = (ToolResponseMessage) message; + + ChatCompletionToolMessageParam.Builder builder = ChatCompletionToolMessageParam.builder(); + builder.content(toolMessage.getText() != null ? toolMessage.getText() : ""); + builder.role(JsonValue.from(MessageType.TOOL.getValue())); + + if (toolMessage.getResponses().isEmpty()) { + return List.of(ChatCompletionMessageParam.ofTool(builder.build())); + } + return toolMessage.getResponses().stream().map(response -> { + String callId = response.id(); + String callResponse = response.responseData(); + + return ChatCompletionMessageParam + .ofTool(builder.toolCallId(callId).content(callResponse).build()); + }).toList(); + } + else { + throw new IllegalArgumentException("Unsupported message type: " + message.getMessageType()); + } + }) + .flatMap(List::stream) + .toList(); + + ChatCompletionCreateParams.Builder builder = ChatCompletionCreateParams.builder(); + + chatCompletionMessageParams.forEach(builder::addMessage); + + OpenAiSdkChatOptions requestOptions = (OpenAiSdkChatOptions) prompt.getOptions(); + + // Use deployment name if available (for Microsoft Foundry), otherwise use model + // name + if (requestOptions.getDeploymentName() != null) { + builder.model(requestOptions.getDeploymentName()); + } + else if (requestOptions.getModel() != null) { + builder.model(requestOptions.getModel()); + } + + if (requestOptions.getFrequencyPenalty() != null) { + builder.frequencyPenalty(requestOptions.getFrequencyPenalty()); + } + if (requestOptions.getLogitBias() != null) { + builder.logitBias(ChatCompletionCreateParams.LogitBias.builder() + .putAllAdditionalProperties(requestOptions.getLogitBias() + .entrySet() + .stream() + .collect(Collectors.toMap(Map.Entry::getKey, entry -> JsonValue.from(entry.getValue())))) + .build()); + } + if (requestOptions.getLogprobs() != null) { + builder.logprobs(requestOptions.getLogprobs()); + } + if (requestOptions.getTopLogprobs() != null) { + builder.topLogprobs(requestOptions.getTopLogprobs()); + } + if (requestOptions.getMaxTokens() != null) { + builder.maxTokens(requestOptions.getMaxTokens()); + } + if (requestOptions.getMaxCompletionTokens() != null) { + builder.maxCompletionTokens(requestOptions.getMaxCompletionTokens()); + } + if (requestOptions.getN() != null) { + builder.n(requestOptions.getN()); + } + if (requestOptions.getOutputModalities() != null) { + builder.modalities(requestOptions.getOutputModalities() + .stream() + .map(modality -> ChatCompletionCreateParams.Modality.of(modality.toLowerCase())) + .toList()); + } + if (requestOptions.getOutputAudio() != null) { + builder.audio(requestOptions.getOutputAudio().toChatCompletionAudioParam()); + } + if (requestOptions.getPresencePenalty() != null) { + builder.presencePenalty(requestOptions.getPresencePenalty()); + } + if (requestOptions.getResponseFormat() != null) { + ResponseFormat responseFormat = requestOptions.getResponseFormat(); + if (responseFormat.getType().equals(ResponseFormat.Type.TEXT)) { + builder.responseFormat(ResponseFormatText.builder().build()); + } + else if (responseFormat.getType().equals(ResponseFormat.Type.JSON_OBJECT)) { + builder.responseFormat(ResponseFormatJsonObject.builder().build()); + } + else if (responseFormat.getType().equals(ResponseFormat.Type.JSON_SCHEMA)) { + String jsonSchemaString = responseFormat.getJsonSchema() != null ? responseFormat.getJsonSchema() : ""; + try { + com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + ResponseFormatJsonSchema.JsonSchema.Builder jsonSchemaBuilder = ResponseFormatJsonSchema.JsonSchema + .builder(); + jsonSchemaBuilder.name("json_schema"); + jsonSchemaBuilder.strict(true); + + ResponseFormatJsonSchema.JsonSchema.Schema schema = mapper.readValue(jsonSchemaString, + ResponseFormatJsonSchema.JsonSchema.Schema.class); + + jsonSchemaBuilder.schema(schema); + + builder.responseFormat( + ResponseFormatJsonSchema.builder().jsonSchema(jsonSchemaBuilder.build()).build()); + } + catch (Exception e) { + throw new IllegalArgumentException("Failed to parse JSON schema: " + jsonSchemaString, e); + } + } + else { + throw new IllegalArgumentException("Unsupported response format type: " + responseFormat.getType()); + } + } + if (requestOptions.getSeed() != null) { + builder.seed(requestOptions.getSeed()); + } + if (requestOptions.getStop() != null && !requestOptions.getStop().isEmpty()) { + if (requestOptions.getStop().size() == 1) { + builder.stop(ChatCompletionCreateParams.Stop.ofString(requestOptions.getStop().get(0))); + } + else { + builder.stop(ChatCompletionCreateParams.Stop.ofStrings(requestOptions.getStop())); + } + } + if (requestOptions.getTemperature() != null) { + builder.temperature(requestOptions.getTemperature()); + } + if (requestOptions.getTopP() != null) { + builder.topP(requestOptions.getTopP()); + } + if (requestOptions.getUser() != null) { + builder.user(requestOptions.getUser()); + } + if (requestOptions.getParallelToolCalls() != null) { + builder.parallelToolCalls(requestOptions.getParallelToolCalls()); + } + if (requestOptions.getReasoningEffort() != null) { + builder.reasoningEffort(ReasoningEffort.of(requestOptions.getReasoningEffort().toLowerCase())); + } + if (requestOptions.getVerbosity() != null) { + builder.verbosity(ChatCompletionCreateParams.Verbosity.of(requestOptions.getVerbosity())); + } + + if (requestOptions.getStore() != null) { + builder.store(requestOptions.getStore()); + } + if (requestOptions.getMetadata() != null && !requestOptions.getMetadata().isEmpty()) { + builder.metadata(ChatCompletionCreateParams.Metadata.builder() + .putAllAdditionalProperties(requestOptions.getMetadata() + .entrySet() + .stream() + .collect(Collectors.toMap(Map.Entry::getKey, entry -> JsonValue.from(entry.getValue())))) + .build()); + } + if (requestOptions.getServiceTier() != null) { + builder.serviceTier(ChatCompletionCreateParams.ServiceTier.of(requestOptions.getServiceTier())); + } + + if (stream) { + if (requestOptions.getStreamOptions() != null) { + ChatCompletionStreamOptions.Builder streamOptionsBuilder = ChatCompletionStreamOptions.builder(); + + var ops = requestOptions.getStreamOptions(); + + streamOptionsBuilder.includeObfuscation(ops.includeObfuscation() != null && ops.includeObfuscation()); + streamOptionsBuilder.includeUsage(ops.includeUsage() != null && ops.includeUsage()); + + if (!CollectionUtils.isEmpty(ops.additionalProperties())) { + Map nativeParams = ops.additionalProperties() + .entrySet() + .stream() + .map(e -> Map.entry(e.getKey(), com.openai.core.JsonValue.from(e.getValue()))) + .collect(HashMap::new, (m, e) -> m.put(e.getKey(), e.getValue()), HashMap::putAll); + + streamOptionsBuilder.putAllAdditionalProperties(nativeParams); + } + builder.streamOptions(streamOptionsBuilder.build()); + } + else { + builder.streamOptions(ChatCompletionStreamOptions.builder() + .includeUsage(true) // Include usage by default for streaming + .build()); + } + } + + // Add the tool definitions to the request's tools parameter. + List toolDefinitions = this.toolCallingManager.resolveToolDefinitions(requestOptions); + if (!CollectionUtils.isEmpty(toolDefinitions)) { + builder.tools(getChatCompletionTools(toolDefinitions)); + } + + if (requestOptions.getToolChoice() != null) { + if (requestOptions.getToolChoice() instanceof ChatCompletionToolChoiceOption toolChoiceOption) { + builder.toolChoice(toolChoiceOption); + } + else if (requestOptions.getToolChoice() instanceof String json) { + try { + var node = ModelOptionsUtils.OBJECT_MAPPER.readTree(json); + builder.toolChoice(parseToolChoice(node)); + } + catch (Exception e) { + throw new IllegalArgumentException("Failed to parse toolChoice JSON: " + json, e); + } + } + } + + return builder.build(); + } + + public static ChatCompletionToolChoiceOption parseToolChoice(JsonNode node) { + String type = node.get("type").asText(); + switch (type) { + case "function": + String functionName = node.get("function").get("name").asText(); + ChatCompletionNamedToolChoice.Function func = ChatCompletionNamedToolChoice.Function.builder() + .name(functionName) + .build(); + ChatCompletionNamedToolChoice named = ChatCompletionNamedToolChoice.builder().function(func).build(); + return ChatCompletionToolChoiceOption.ofNamedToolChoice(named); + case "auto": + // There is a built-in “auto” option — but how to get it depends on SDK + // version + return ChatCompletionToolChoiceOption.ofAuto(ChatCompletionToolChoiceOption.Auto.AUTO); + case "required": + // There may or may not be a 'required' option; if SDK supports, you need + // a way to construct it + // If it's not supported, you must use JSON fallback + throw new UnsupportedOperationException("SDK version does not support typed 'required' toolChoice"); + case "none": + // Similarly for none + throw new UnsupportedOperationException("SDK version does not support typed 'none' toolChoice"); + default: + throw new IllegalArgumentException("Unknown tool_choice type: " + type); + } + } + + private String fromAudioData(Object audioData) { + if (audioData instanceof byte[] bytes) { + return Base64.getEncoder().encodeToString(bytes); + } + throw new IllegalArgumentException("Unsupported audio data type: " + audioData.getClass().getSimpleName()); + } + + private String fromMediaData(org.springframework.util.MimeType mimeType, Object mediaContentData) { + if (mediaContentData instanceof byte[] bytes) { + // Assume the bytes are an image. So, convert the bytes to a base64 encoded + // following the prefix pattern. + return String.format("data:%s;base64,%s", mimeType.toString(), Base64.getEncoder().encodeToString(bytes)); + } + else if (mediaContentData instanceof String text) { + // Assume the text is a URLs or a base64 encoded image prefixed by the user. + return text; + } + else { + throw new IllegalArgumentException( + "Unsupported media data type: " + mediaContentData.getClass().getSimpleName()); + } + } + + private List getChatCompletionTools(List toolDefinitions) { + return toolDefinitions.stream().map(toolDefinition -> { + FunctionParameters.Builder parametersBuilder = FunctionParameters.builder(); + + if (!toolDefinition.inputSchema().isEmpty()) { + // Parse the schema and add its properties directly + try { + com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + @SuppressWarnings("unchecked") + Map schemaMap = mapper.readValue(toolDefinition.inputSchema(), Map.class); + + // Add each property from the schema to the parameters + schemaMap + .forEach((key, value) -> parametersBuilder.putAdditionalProperty(key, JsonValue.from(value))); + + // Add strict mode + parametersBuilder.putAdditionalProperty("strict", JsonValue.from(true)); // TODO + // allow + // non-strict + // mode + } + catch (Exception e) { + logger.error("Failed to parse tool schema", e); + } + } + + FunctionDefinition functionDefinition = FunctionDefinition.builder() + .name(toolDefinition.name()) + .description(toolDefinition.description()) + .parameters(parametersBuilder.build()) + .build(); + + return ChatCompletionTool + .ofFunction(ChatCompletionFunctionTool.builder().function(functionDefinition).build()); + }).toList(); + } + + @Override + public ChatOptions getDefaultOptions() { + return this.options.copy(); + } + + /** + * Use the provided convention for reporting observation data + * @param observationConvention The provided convention + */ + public void setObservationConvention(ChatModelObservationConvention observationConvention) { + Assert.notNull(observationConvention, "observationConvention cannot be null"); + this.observationConvention = observationConvention; + } + + /** + * Response format (text, json_object, json_schema) for OpenAiSdkChatModel responses. + * + * @author Julien Dubois + */ + public static class ResponseFormat { + + private Type type = Type.TEXT; + + private String jsonSchema; + + public Type getType() { + return this.type; + } + + public void setType(Type type) { + this.type = type; + } + + public String getJsonSchema() { + return this.jsonSchema; + } + + public void setJsonSchema(String jsonSchema) { + this.jsonSchema = jsonSchema; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private final ResponseFormat responseFormat = new ResponseFormat(); + + private Builder() { + } + + public Builder type(Type type) { + this.responseFormat.setType(type); + return this; + } + + public Builder jsonSchema(String jsonSchema) { + this.responseFormat.setType(Type.JSON_SCHEMA); + this.responseFormat.setJsonSchema(jsonSchema); + return this; + } + + public ResponseFormat build() { + return this.responseFormat; + } + + } + + public enum Type { + + /** + * Generates a text response. (default) + */ + TEXT, + + /** + * Enables JSON mode, which guarantees the message the model generates is + * valid JSON. + */ + JSON_OBJECT, + + /** + * Enables Structured Outputs which guarantees the model will match your + * supplied JSON schema. + */ + JSON_SCHEMA + + } + + } + + /** + * Helper class to merge streaming tool calls that arrive in pieces across multiple + * chunks. In OpenAI streaming, a tool call's ID, name, and arguments can arrive in + * separate chunks. + */ + private static class ToolCallBuilder { + + private String id = ""; + + private String type = "function"; + + private String name = ""; + + private StringBuilder arguments = new StringBuilder(); + + void merge(AssistantMessage.ToolCall toolCall) { + if (toolCall.id() != null && !toolCall.id().isEmpty()) { + this.id = toolCall.id(); + } + if (toolCall.type() != null && !toolCall.type().isEmpty()) { + this.type = toolCall.type(); + } + if (toolCall.name() != null && !toolCall.name().isEmpty()) { + this.name = toolCall.name(); + } + if (toolCall.arguments() != null && !toolCall.arguments().isEmpty()) { + this.arguments.append(toolCall.arguments()); + } + } + + AssistantMessage.ToolCall build() { + return new AssistantMessage.ToolCall(this.id, this.type, this.name, this.arguments.toString()); + } + + } + +} diff --git a/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/OpenAiSdkChatOptions.java b/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/OpenAiSdkChatOptions.java new file mode 100644 index 00000000000..a2a2609d943 --- /dev/null +++ b/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/OpenAiSdkChatOptions.java @@ -0,0 +1,1139 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.openaisdk; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.openai.models.ChatModel; +import com.openai.models.chat.completions.ChatCompletionAudioParam; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.model.tool.StructuredOutputChatOptions; +import org.springframework.ai.model.tool.ToolCallingChatOptions; +import org.springframework.ai.openaisdk.OpenAiSdkChatModel.ResponseFormat.Type; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Configuration information for the Chat Model implementation using the OpenAI Java SDK. + * + * @author Julien Dubois + * @author Christian Tzolov + */ +public class OpenAiSdkChatOptions extends AbstractOpenAiSdkOptions + implements ToolCallingChatOptions, StructuredOutputChatOptions { + + public static final String DEFAULT_CHAT_MODEL = ChatModel.GPT_5_MINI.asString(); + + private static final Logger logger = LoggerFactory.getLogger(OpenAiSdkChatOptions.class); + + private Double frequencyPenalty; + + private Map logitBias; + + private Boolean logprobs; + + private Integer topLogprobs; + + private Integer maxTokens; + + private Integer maxCompletionTokens; + + private Integer n; + + private List outputModalities; + + private AudioParameters outputAudio; + + private Double presencePenalty; + + private OpenAiSdkChatModel.ResponseFormat responseFormat; + + private StreamOptions streamOptions; + + private Integer seed; + + private List stop; + + private Double temperature; + + private Double topP; + + private Object toolChoice; + + private String user; + + private Boolean parallelToolCalls; + + private Boolean store; + + private Map metadata; + + private String reasoningEffort; + + private String verbosity; + + private String serviceTier; + + private List toolCallbacks = new ArrayList<>(); + + private Set toolNames = new HashSet<>(); + + private Boolean internalToolExecutionEnabled; + + private Map toolContext = new HashMap<>(); + + /** + * Gets the frequency penalty parameter. + * @return the frequency penalty + */ + @Override + public Double getFrequencyPenalty() { + return this.frequencyPenalty; + } + + /** + * Sets the frequency penalty parameter. + * @param frequencyPenalty the frequency penalty to set + */ + public void setFrequencyPenalty(Double frequencyPenalty) { + this.frequencyPenalty = frequencyPenalty; + } + + /** + * Gets the logit bias map. + * @return the logit bias map + */ + public Map getLogitBias() { + return this.logitBias; + } + + /** + * Sets the logit bias map. + * @param logitBias the logit bias map to set + */ + public void setLogitBias(Map logitBias) { + this.logitBias = logitBias; + } + + /** + * Gets whether to return log probabilities. + * @return true if log probabilities should be returned + */ + public Boolean getLogprobs() { + return this.logprobs; + } + + /** + * Sets whether to return log probabilities. + * @param logprobs whether to return log probabilities + */ + public void setLogprobs(Boolean logprobs) { + this.logprobs = logprobs; + } + + /** + * Gets the number of top log probabilities to return. + * @return the number of top log probabilities + */ + public Integer getTopLogprobs() { + return this.topLogprobs; + } + + /** + * Sets the number of top log probabilities to return. + * @param topLogprobs the number of top log probabilities + */ + public void setTopLogprobs(Integer topLogprobs) { + this.topLogprobs = topLogprobs; + } + + @Override + public Integer getMaxTokens() { + return this.maxTokens; + } + + /** + * Sets the maximum number of tokens to generate. + * @param maxTokens the maximum number of tokens + */ + public void setMaxTokens(Integer maxTokens) { + this.maxTokens = maxTokens; + } + + /** + * Gets the maximum number of completion tokens. + * @return the maximum number of completion tokens + */ + public Integer getMaxCompletionTokens() { + return this.maxCompletionTokens; + } + + /** + * Sets the maximum number of completion tokens. + * @param maxCompletionTokens the maximum number of completion tokens + */ + public void setMaxCompletionTokens(Integer maxCompletionTokens) { + this.maxCompletionTokens = maxCompletionTokens; + } + + /** + * Gets the number of completions to generate. + * @return the number of completions + */ + public Integer getN() { + return this.n; + } + + /** + * Sets the number of completions to generate. + * @param n the number of completions + */ + public void setN(Integer n) { + this.n = n; + } + + /** + * Gets the output modalities. + * @return the output modalities + */ + public List getOutputModalities() { + return this.outputModalities; + } + + /** + * Sets the output modalities. + * @param outputModalities the output modalities + */ + public void setOutputModalities(List outputModalities) { + this.outputModalities = outputModalities; + } + + /** + * Gets the output audio parameters. + * @return the output audio parameters + */ + public AudioParameters getOutputAudio() { + return this.outputAudio; + } + + /** + * Sets the output audio parameters. + * @param outputAudio the output audio parameters + */ + public void setOutputAudio(AudioParameters outputAudio) { + this.outputAudio = outputAudio; + } + + @Override + public Double getPresencePenalty() { + return this.presencePenalty; + } + + /** + * Sets the presence penalty parameter. + * @param presencePenalty the presence penalty to set + */ + public void setPresencePenalty(Double presencePenalty) { + this.presencePenalty = presencePenalty; + } + + /** + * Gets the response format configuration. + * @return the response format + */ + public OpenAiSdkChatModel.ResponseFormat getResponseFormat() { + return this.responseFormat; + } + + /** + * Sets the response format configuration. + * @param responseFormat the response format to set + */ + public void setResponseFormat(OpenAiSdkChatModel.ResponseFormat responseFormat) { + this.responseFormat = responseFormat; + } + + /** + * Gets the stream options. + * @return the stream options + */ + public StreamOptions getStreamOptions() { + return this.streamOptions; + } + + /** + * Sets the stream options. + * @param streamOptions the stream options to set + */ + public void setStreamOptions(StreamOptions streamOptions) { + this.streamOptions = streamOptions; + } + + /** + * Gets the random seed for deterministic generation. + * @return the random seed + */ + public Integer getSeed() { + return this.seed; + } + + /** + * Sets the random seed for deterministic generation. + * @param seed the random seed + */ + public void setSeed(Integer seed) { + this.seed = seed; + } + + /** + * Gets the stop sequences. + * @return the list of stop sequences + */ + public List getStop() { + return this.stop; + } + + /** + * Sets the stop sequences. + * @param stop the list of stop sequences + */ + public void setStop(List stop) { + this.stop = stop; + } + + @Override + public List getStopSequences() { + return getStop(); + } + + /** + * Sets the stop sequences. + * @param stopSequences the list of stop sequences + */ + public void setStopSequences(List stopSequences) { + setStop(stopSequences); + } + + @Override + public Double getTemperature() { + return this.temperature; + } + + /** + * Sets the temperature for sampling. + * @param temperature the temperature value + */ + public void setTemperature(Double temperature) { + this.temperature = temperature; + } + + @Override + public Double getTopP() { + return this.topP; + } + + /** + * Sets the top-p nucleus sampling parameter. + * @param topP the top-p value + */ + public void setTopP(Double topP) { + this.topP = topP; + } + + /** + * Gets the tool choice configuration. + * @return the tool choice option + */ + public Object getToolChoice() { + return this.toolChoice; + } + + /** + * Sets the tool choice configuration. + * @param toolChoice the tool choice option + */ + public void setToolChoice(Object toolChoice) { + this.toolChoice = toolChoice; + } + + /** + * Gets the user identifier. + * @return the user identifier + */ + public String getUser() { + return this.user; + } + + /** + * Sets the user identifier. + * @param user the user identifier + */ + public void setUser(String user) { + this.user = user; + } + + /** + * Gets whether to enable parallel tool calls. + * @return true if parallel tool calls are enabled + */ + public Boolean getParallelToolCalls() { + return this.parallelToolCalls; + } + + /** + * Sets whether to enable parallel tool calls. + * @param parallelToolCalls whether to enable parallel tool calls + */ + public void setParallelToolCalls(Boolean parallelToolCalls) { + this.parallelToolCalls = parallelToolCalls; + } + + /** + * Gets whether to store the conversation. + * @return true if the conversation should be stored + */ + public Boolean getStore() { + return this.store; + } + + /** + * Sets whether to store the conversation. + * @param store whether to store the conversation + */ + public void setStore(Boolean store) { + this.store = store; + } + + /** + * Gets the metadata map. + * @return the metadata map + */ + public Map getMetadata() { + return this.metadata; + } + + /** + * Sets the metadata map. + * @param metadata the metadata map + */ + public void setMetadata(Map metadata) { + this.metadata = metadata; + } + + /** + * Gets the reasoning effort level. + * @return the reasoning effort level + */ + public String getReasoningEffort() { + return this.reasoningEffort; + } + + /** + * Sets the reasoning effort level. + * @param reasoningEffort the reasoning effort level + */ + public void setReasoningEffort(String reasoningEffort) { + this.reasoningEffort = reasoningEffort; + } + + /** + * Gets the verbosity level. + * @return the verbosity level + */ + public String getVerbosity() { + return this.verbosity; + } + + /** + * Sets the verbosity level. + * @param verbosity the verbosity level + */ + public void setVerbosity(String verbosity) { + this.verbosity = verbosity; + } + + /** + * Gets the service tier. + * @return the service tier + */ + public String getServiceTier() { + return this.serviceTier; + } + + /** + * Sets the service tier. + * @param serviceTier the service tier + */ + public void setServiceTier(String serviceTier) { + this.serviceTier = serviceTier; + } + + @Override + public List getToolCallbacks() { + return this.toolCallbacks; + } + + @Override + public void setToolCallbacks(List toolCallbacks) { + Assert.notNull(toolCallbacks, "toolCallbacks cannot be null"); + Assert.noNullElements(toolCallbacks, "toolCallbacks cannot contain null elements"); + this.toolCallbacks = toolCallbacks; + } + + @Override + public Set getToolNames() { + return this.toolNames; + } + + @Override + public void setToolNames(Set toolNames) { + Assert.notNull(toolNames, "toolNames cannot be null"); + Assert.noNullElements(toolNames, "toolNames cannot contain null elements"); + toolNames.forEach(tool -> Assert.hasText(tool, "toolNames cannot contain empty elements")); + this.toolNames = toolNames; + } + + @Override + @Nullable + public Boolean getInternalToolExecutionEnabled() { + return this.internalToolExecutionEnabled; + } + + @Override + public void setInternalToolExecutionEnabled(@Nullable Boolean internalToolExecutionEnabled) { + this.internalToolExecutionEnabled = internalToolExecutionEnabled; + } + + @Override + public Map getToolContext() { + return this.toolContext; + } + + @Override + public void setToolContext(Map toolContext) { + this.toolContext = toolContext; + } + + @Override + public Integer getTopK() { + return null; + } + + @Override + @JsonIgnore + public String getOutputSchema() { + return this.getResponseFormat().getJsonSchema(); + } + + @Override + @JsonIgnore + public void setOutputSchema(String outputSchema) { + this.setResponseFormat( + OpenAiSdkChatModel.ResponseFormat.builder().type(Type.JSON_SCHEMA).jsonSchema(outputSchema).build()); + } + + public static Builder builder() { + return new Builder(); + } + + @Override + public OpenAiSdkChatOptions copy() { + return builder().from(this).build(); + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + OpenAiSdkChatOptions options = (OpenAiSdkChatOptions) o; + return Objects.equals(this.getModel(), options.getModel()) + && Objects.equals(this.frequencyPenalty, options.frequencyPenalty) + && Objects.equals(this.logitBias, options.logitBias) && Objects.equals(this.logprobs, options.logprobs) + && Objects.equals(this.temperature, options.temperature) + && Objects.equals(this.maxTokens, options.maxTokens) + && Objects.equals(this.maxCompletionTokens, options.maxCompletionTokens) + && Objects.equals(this.n, options.n) && Objects.equals(this.outputModalities, options.outputModalities) + && Objects.equals(this.outputAudio, options.outputAudio) + && Objects.equals(this.presencePenalty, options.presencePenalty) + && Objects.equals(this.responseFormat, options.responseFormat) + && Objects.equals(this.streamOptions, options.streamOptions) && Objects.equals(this.seed, options.seed) + && Objects.equals(this.stop, options.stop) && Objects.equals(this.temperature, options.temperature) + && Objects.equals(this.topP, options.topP) && Objects.equals(this.toolChoice, options.toolChoice) + && Objects.equals(this.user, options.user) + && Objects.equals(this.parallelToolCalls, options.parallelToolCalls) + && Objects.equals(this.store, options.store) && Objects.equals(this.metadata, options.metadata) + && Objects.equals(this.reasoningEffort, options.reasoningEffort) + && Objects.equals(this.verbosity, options.verbosity) + && Objects.equals(this.serviceTier, options.serviceTier) + && Objects.equals(this.toolCallbacks, options.toolCallbacks) + && Objects.equals(this.toolNames, options.toolNames) + && Objects.equals(this.internalToolExecutionEnabled, options.internalToolExecutionEnabled) + && Objects.equals(this.toolContext, options.toolContext); + } + + @Override + public int hashCode() { + return Objects.hash(this.getModel(), this.frequencyPenalty, this.logitBias, this.logprobs, this.topLogprobs, + this.maxTokens, this.maxCompletionTokens, this.n, this.outputModalities, this.outputAudio, + this.presencePenalty, this.responseFormat, this.streamOptions, this.seed, this.stop, this.temperature, + this.topP, this.toolChoice, this.user, this.parallelToolCalls, this.store, this.metadata, + this.reasoningEffort, this.verbosity, this.serviceTier, this.toolCallbacks, this.toolNames, + this.internalToolExecutionEnabled, this.toolContext); + } + + @Override + public String toString() { + return "OpenAiSdkChatOptions{" + "model='" + this.getModel() + ", frequencyPenalty=" + this.frequencyPenalty + + ", logitBias=" + this.logitBias + ", logprobs=" + this.logprobs + ", topLogprobs=" + this.topLogprobs + + ", maxTokens=" + this.maxTokens + ", maxCompletionTokens=" + this.maxCompletionTokens + ", n=" + + this.n + ", outputModalities=" + this.outputModalities + ", outputAudio=" + this.outputAudio + + ", presencePenalty=" + this.presencePenalty + ", responseFormat=" + this.responseFormat + + ", streamOptions=" + this.streamOptions + ", streamUsage=" + ", seed=" + this.seed + ", stop=" + + this.stop + ", temperature=" + this.temperature + ", topP=" + this.topP + ", toolChoice=" + + this.toolChoice + ", user='" + this.user + '\'' + ", parallelToolCalls=" + this.parallelToolCalls + + ", store=" + this.store + ", metadata=" + this.metadata + ", reasoningEffort='" + this.reasoningEffort + + '\'' + ", verbosity='" + this.verbosity + '\'' + ", serviceTier='" + this.serviceTier + '\'' + + ", toolCallbacks=" + this.toolCallbacks + ", toolNames=" + this.toolNames + + ", internalToolExecutionEnabled=" + this.internalToolExecutionEnabled + ", toolContext=" + + this.toolContext + '}'; + } + + public record AudioParameters(Voice voice, AudioResponseFormat format) { + + /** + * Specifies the voice type. + */ + public enum Voice { + + ALLOY, ASH, BALLAD, CORAL, ECHO, FABLE, ONYX, NOVA, SAGE, SHIMMER + + } + + /** + * Specifies the output audio format. + */ + public enum AudioResponseFormat { + + MP3, FLAC, OPUS, PCM16, WAV, AAC + + } + + public ChatCompletionAudioParam toChatCompletionAudioParam() { + ChatCompletionAudioParam.Builder builder = ChatCompletionAudioParam.builder(); + if (this.voice() != null) { + builder.voice(voice().name().toLowerCase()); + } + if (this.format() != null) { + builder.format(ChatCompletionAudioParam.Format.of(this.format().name().toLowerCase())); + } + return builder.build(); + } + } + + public record StreamOptions(Boolean includeObfuscation, Boolean includeUsage, + Map additionalProperties) { + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private Boolean includeObfuscation; + + private Boolean includeUsage; + + private Map additionalProperties = new HashMap<>(); + + public Builder from(StreamOptions fromOptions) { + if (fromOptions != null) { + this.includeObfuscation = fromOptions.includeObfuscation(); + this.includeUsage = fromOptions.includeUsage(); + this.additionalProperties = fromOptions.additionalProperties() != null + ? new HashMap<>(fromOptions.additionalProperties()) : new HashMap<>(); + } + return this; + } + + public Builder includeObfuscation(Boolean includeObfuscation) { + this.includeObfuscation = includeObfuscation; + return this; + } + + public Builder includeUsage(Boolean includeUsage) { + this.includeUsage = includeUsage; + return this; + } + + public Builder additionalProperties(Map additionalProperties) { + this.additionalProperties = additionalProperties != null ? new HashMap<>(additionalProperties) + : new HashMap<>(); + return this; + } + + public Builder additionalProperty(String key, Object value) { + if (this.additionalProperties == null) { + this.additionalProperties = new HashMap<>(); + } + this.additionalProperties.put(key, value); + return this; + } + + public StreamOptions build() { + return new StreamOptions(this.includeObfuscation, this.includeUsage, this.additionalProperties); + } + + } + + } + + public static final class Builder { + + private final OpenAiSdkChatOptions options = new OpenAiSdkChatOptions(); + + public Builder from(OpenAiSdkChatOptions fromOptions) { + // Parent class fields + this.options.setBaseUrl(fromOptions.getBaseUrl()); + this.options.setApiKey(fromOptions.getApiKey()); + this.options.setCredential(fromOptions.getCredential()); + this.options.setModel(fromOptions.getModel()); + this.options.setDeploymentName(fromOptions.getDeploymentName()); + this.options.setMicrosoftFoundryServiceVersion(fromOptions.getMicrosoftFoundryServiceVersion()); + this.options.setOrganizationId(fromOptions.getOrganizationId()); + this.options.setMicrosoftFoundry(fromOptions.isMicrosoftFoundry()); + this.options.setGitHubModels(fromOptions.isGitHubModels()); + this.options.setTimeout(fromOptions.getTimeout()); + this.options.setMaxRetries(fromOptions.getMaxRetries()); + this.options.setProxy(fromOptions.getProxy()); + this.options.setCustomHeaders( + fromOptions.getCustomHeaders() != null ? new HashMap<>(fromOptions.getCustomHeaders()) : null); + // Child class fields + this.options.setFrequencyPenalty(fromOptions.getFrequencyPenalty()); + this.options.setLogitBias(fromOptions.getLogitBias()); + this.options.setLogprobs(fromOptions.getLogprobs()); + this.options.setTopLogprobs(fromOptions.getTopLogprobs()); + this.options.setMaxTokens(fromOptions.getMaxTokens()); + this.options.setMaxCompletionTokens(fromOptions.getMaxCompletionTokens()); + this.options.setN(fromOptions.getN()); + this.options.setOutputModalities(fromOptions.getOutputModalities()); + this.options.setOutputAudio(fromOptions.getOutputAudio()); + this.options.setPresencePenalty(fromOptions.getPresencePenalty()); + this.options.setResponseFormat(fromOptions.getResponseFormat()); + this.options.setStreamOptions(fromOptions.getStreamOptions()); + this.options.setSeed(fromOptions.getSeed()); + this.options.setStop(fromOptions.getStop() != null ? new ArrayList<>(fromOptions.getStop()) : null); + this.options.setTemperature(fromOptions.getTemperature()); + this.options.setTopP(fromOptions.getTopP()); + this.options.setToolChoice(fromOptions.getToolChoice()); + this.options.setUser(fromOptions.getUser()); + this.options.setParallelToolCalls(fromOptions.getParallelToolCalls()); + this.options.setToolCallbacks(new ArrayList<>(fromOptions.getToolCallbacks())); + this.options.setToolNames(new HashSet<>(fromOptions.getToolNames())); + this.options.setInternalToolExecutionEnabled(fromOptions.getInternalToolExecutionEnabled()); + this.options.setToolContext(new HashMap<>(fromOptions.getToolContext())); + this.options.setStore(fromOptions.getStore()); + this.options.setMetadata(fromOptions.getMetadata()); + this.options.setReasoningEffort(fromOptions.getReasoningEffort()); + this.options.setVerbosity(fromOptions.getVerbosity()); + this.options.setServiceTier(fromOptions.getServiceTier()); + return this; + } + + public Builder merge(OpenAiSdkChatOptions from) { + // Parent class fields + if (from.getBaseUrl() != null) { + this.options.setBaseUrl(from.getBaseUrl()); + } + if (from.getApiKey() != null) { + this.options.setApiKey(from.getApiKey()); + } + if (from.getCredential() != null) { + this.options.setCredential(from.getCredential()); + } + if (from.getModel() != null) { + this.options.setModel(from.getModel()); + } + if (from.getDeploymentName() != null) { + this.options.setDeploymentName(from.getDeploymentName()); + } + if (from.getMicrosoftFoundryServiceVersion() != null) { + this.options.setMicrosoftFoundryServiceVersion(from.getMicrosoftFoundryServiceVersion()); + } + if (from.getOrganizationId() != null) { + this.options.setOrganizationId(from.getOrganizationId()); + } + this.options.setMicrosoftFoundry(from.isMicrosoftFoundry()); + this.options.setGitHubModels(from.isGitHubModels()); + if (from.getTimeout() != null) { + this.options.setTimeout(from.getTimeout()); + } + if (from.getMaxRetries() != null) { + this.options.setMaxRetries(from.getMaxRetries()); + } + if (from.getProxy() != null) { + this.options.setProxy(from.getProxy()); + } + if (from.getCustomHeaders() != null) { + this.options.setCustomHeaders(from.getCustomHeaders()); + } + // Child class fields + if (from.getFrequencyPenalty() != null) { + this.options.setFrequencyPenalty(from.getFrequencyPenalty()); + } + if (from.getLogitBias() != null) { + this.options.setLogitBias(from.getLogitBias()); + } + if (from.getLogprobs() != null) { + this.options.setLogprobs(from.getLogprobs()); + } + if (from.getTopLogprobs() != null) { + this.options.setTopLogprobs(from.getTopLogprobs()); + } + if (from.getMaxTokens() != null) { + this.options.setMaxTokens(from.getMaxTokens()); + } + if (from.getMaxCompletionTokens() != null) { + this.options.setMaxCompletionTokens(from.getMaxCompletionTokens()); + } + if (from.getN() != null) { + this.options.setN(from.getN()); + } + if (from.getOutputModalities() != null) { + this.options.setOutputModalities(new ArrayList<>(from.getOutputModalities())); + } + if (from.getOutputAudio() != null) { + this.options.setOutputAudio(from.getOutputAudio()); + } + if (from.getPresencePenalty() != null) { + this.options.setPresencePenalty(from.getPresencePenalty()); + } + if (from.getResponseFormat() != null) { + this.options.setResponseFormat(from.getResponseFormat()); + } + if (from.getStreamOptions() != null) { + this.options.setStreamOptions(from.getStreamOptions()); + } + if (from.getSeed() != null) { + this.options.setSeed(from.getSeed()); + } + if (from.getStop() != null) { + this.options.setStop(new ArrayList<>(from.getStop())); + } + if (from.getTemperature() != null) { + this.options.setTemperature(from.getTemperature()); + } + if (from.getTopP() != null) { + this.options.setTopP(from.getTopP()); + } + if (from.getToolChoice() != null) { + this.options.setToolChoice(from.getToolChoice()); + } + if (from.getUser() != null) { + this.options.setUser(from.getUser()); + } + if (from.getParallelToolCalls() != null) { + this.options.setParallelToolCalls(from.getParallelToolCalls()); + } + if (!from.getToolCallbacks().isEmpty()) { + this.options.setToolCallbacks(new ArrayList<>(from.getToolCallbacks())); + } + if (!from.getToolNames().isEmpty()) { + this.options.setToolNames(new HashSet<>(from.getToolNames())); + } + if (from.getInternalToolExecutionEnabled() != null) { + this.options.setInternalToolExecutionEnabled(from.getInternalToolExecutionEnabled()); + } + if (!from.getToolContext().isEmpty()) { + this.options.setToolContext(new HashMap<>(from.getToolContext())); + } + if (from.getStore() != null) { + this.options.setStore(from.getStore()); + } + if (from.getMetadata() != null) { + this.options.setMetadata(from.getMetadata()); + } + if (from.getReasoningEffort() != null) { + this.options.setReasoningEffort(from.getReasoningEffort()); + } + if (from.getVerbosity() != null) { + this.options.setVerbosity(from.getVerbosity()); + } + if (from.getServiceTier() != null) { + this.options.setServiceTier(from.getServiceTier()); + } + return this; + } + + public Builder model(String model) { + this.options.setModel(model); + return this; + } + + public Builder deploymentName(String deploymentName) { + this.options.setDeploymentName(deploymentName); + return this; + } + + public Builder baseUrl(String baseUrl) { + this.options.setBaseUrl(baseUrl); + return this; + } + + public Builder apiKey(String apiKey) { + this.options.setApiKey(apiKey); + return this; + } + + public Builder credential(com.openai.credential.Credential credential) { + this.options.setCredential(credential); + return this; + } + + public Builder azureOpenAIServiceVersion(com.openai.azure.AzureOpenAIServiceVersion azureOpenAIServiceVersion) { + this.options.setMicrosoftFoundryServiceVersion(azureOpenAIServiceVersion); + return this; + } + + public Builder organizationId(String organizationId) { + this.options.setOrganizationId(organizationId); + return this; + } + + public Builder azure(boolean azure) { + this.options.setMicrosoftFoundry(azure); + return this; + } + + public Builder gitHubModels(boolean gitHubModels) { + this.options.setGitHubModels(gitHubModels); + return this; + } + + public Builder timeout(java.time.Duration timeout) { + this.options.setTimeout(timeout); + return this; + } + + public Builder maxRetries(Integer maxRetries) { + this.options.setMaxRetries(maxRetries); + return this; + } + + public Builder proxy(java.net.Proxy proxy) { + this.options.setProxy(proxy); + return this; + } + + public Builder customHeaders(Map customHeaders) { + this.options.setCustomHeaders(customHeaders); + return this; + } + + public Builder frequencyPenalty(Double frequencyPenalty) { + this.options.setFrequencyPenalty(frequencyPenalty); + return this; + } + + public Builder logitBias(Map logitBias) { + this.options.setLogitBias(logitBias); + return this; + } + + public Builder logprobs(Boolean logprobs) { + this.options.setLogprobs(logprobs); + return this; + } + + public Builder topLogprobs(Integer topLogprobs) { + this.options.setTopLogprobs(topLogprobs); + return this; + } + + public Builder maxTokens(Integer maxTokens) { + if (maxTokens != null && this.options.getMaxCompletionTokens() != null) { + logger.warn( + "Both maxTokens and maxCompletionTokens are set. OpenAI API does not support setting both parameters simultaneously. " + + "As maxToken is deprecated, we will ignore it and use maxCompletionToken ({}).", + this.options.getMaxCompletionTokens()); + } + else { + this.options.setMaxTokens(maxTokens); + } + return this; + } + + public Builder maxCompletionTokens(Integer maxCompletionTokens) { + if (maxCompletionTokens != null && this.options.getMaxTokens() != null) { + logger.warn( + "Both maxTokens and maxCompletionTokens are set. OpenAI API does not support setting both parameters simultaneously. " + + "As maxToken is deprecated, we will use maxCompletionToken ({}).", + maxCompletionTokens); + + this.options.setMaxTokens(null); + } + this.options.setMaxCompletionTokens(maxCompletionTokens); + return this; + } + + public Builder N(Integer n) { + this.options.setN(n); + return this; + } + + public Builder outputModalities(List outputModalities) { + this.options.setOutputModalities(outputModalities); + return this; + } + + public Builder outputAudio(AudioParameters audio) { + this.options.setOutputAudio(audio); + return this; + } + + public Builder presencePenalty(Double presencePenalty) { + this.options.setPresencePenalty(presencePenalty); + return this; + } + + public Builder responseFormat(OpenAiSdkChatModel.ResponseFormat responseFormat) { + this.options.setResponseFormat(responseFormat); + return this; + } + + public Builder streamOptions(StreamOptions streamOptions) { + this.options.setStreamOptions(streamOptions); + return this; + } + + // helper shortcut methods for StreamOptions with included stream usage + public Builder streamUsage(boolean streamUsage) { + this.options.setStreamOptions( + StreamOptions.builder().from(this.options.getStreamOptions()).includeUsage(streamUsage).build()); + return this; + } + + public Builder seed(Integer seed) { + this.options.setSeed(seed); + return this; + } + + public Builder stop(List stop) { + this.options.setStop(stop); + return this; + } + + public Builder temperature(Double temperature) { + this.options.setTemperature(temperature); + return this; + } + + public Builder topP(Double topP) { + this.options.setTopP(topP); + return this; + } + + public Builder toolChoice(Object toolChoice) { + this.options.setToolChoice(toolChoice); + return this; + } + + public Builder user(String user) { + this.options.setUser(user); + return this; + } + + public Builder parallelToolCalls(Boolean parallelToolCalls) { + this.options.setParallelToolCalls(parallelToolCalls); + return this; + } + + public Builder toolCallbacks(List toolCallbacks) { + this.options.setToolCallbacks(toolCallbacks); + return this; + } + + public Builder toolCallbacks(ToolCallback... toolCallbacks) { + this.options.setToolCallbacks(Arrays.asList(toolCallbacks)); + return this; + } + + public Builder toolNames(Set toolNames) { + Assert.notNull(toolNames, "toolNames cannot be null"); + this.options.setToolNames(toolNames); + return this; + } + + public Builder toolNames(String... toolNames) { + Assert.notNull(toolNames, "toolNames cannot be null"); + this.options.setToolNames(new HashSet<>(Arrays.asList(toolNames))); + return this; + } + + public Builder internalToolExecutionEnabled(@Nullable Boolean internalToolExecutionEnabled) { + this.options.setInternalToolExecutionEnabled(internalToolExecutionEnabled); + return this; + } + + public Builder toolContext(Map toolContext) { + this.options.setToolContext(toolContext); + return this; + } + + public Builder store(Boolean store) { + this.options.setStore(store); + return this; + } + + public Builder metadata(Map metadata) { + this.options.setMetadata(metadata); + return this; + } + + public Builder reasoningEffort(String reasoningEffort) { + this.options.setReasoningEffort(reasoningEffort); + return this; + } + + public Builder verbosity(String verbosity) { + this.options.setVerbosity(verbosity); + return this; + } + + public Builder serviceTier(String serviceTier) { + this.options.setServiceTier(serviceTier); + return this; + } + + public OpenAiSdkChatOptions build() { + return this.options; + } + + } + +} diff --git a/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/OpenAiSdkEmbeddingModel.java b/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/OpenAiSdkEmbeddingModel.java new file mode 100644 index 00000000000..7540168d815 --- /dev/null +++ b/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/OpenAiSdkEmbeddingModel.java @@ -0,0 +1,263 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.openaisdk; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import com.openai.client.OpenAIClient; +import com.openai.models.embeddings.CreateEmbeddingResponse; +import com.openai.models.embeddings.EmbeddingCreateParams; +import io.micrometer.observation.ObservationRegistry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.chat.metadata.DefaultUsage; +import org.springframework.ai.document.Document; +import org.springframework.ai.document.MetadataMode; +import org.springframework.ai.embedding.AbstractEmbeddingModel; +import org.springframework.ai.embedding.Embedding; +import org.springframework.ai.embedding.EmbeddingRequest; +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.ai.embedding.EmbeddingResponseMetadata; +import org.springframework.ai.embedding.observation.DefaultEmbeddingModelObservationConvention; +import org.springframework.ai.embedding.observation.EmbeddingModelObservationContext; +import org.springframework.ai.embedding.observation.EmbeddingModelObservationConvention; +import org.springframework.ai.embedding.observation.EmbeddingModelObservationDocumentation; +import org.springframework.ai.model.EmbeddingUtils; +import org.springframework.ai.observation.conventions.AiProvider; +import org.springframework.ai.openaisdk.setup.OpenAiSdkSetup; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; + +/** + * Embedding Model implementation using the OpenAI Java SDK. + * + * @author Julien Dubois + */ +public class OpenAiSdkEmbeddingModel extends AbstractEmbeddingModel { + + private static final String DEFAULT_MODEL_NAME = OpenAiSdkEmbeddingOptions.DEFAULT_EMBEDDING_MODEL; + + private static final EmbeddingModelObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultEmbeddingModelObservationConvention(); + + private static final Logger logger = LoggerFactory.getLogger(OpenAiSdkEmbeddingModel.class); + + private final OpenAIClient openAiClient; + + private final OpenAiSdkEmbeddingOptions options; + + private final MetadataMode metadataMode; + + private final ObservationRegistry observationRegistry; + + private EmbeddingModelObservationConvention observationConvention = DEFAULT_OBSERVATION_CONVENTION; + + /** + * Creates a new OpenAiSdkEmbeddingModel with default options. + */ + public OpenAiSdkEmbeddingModel() { + this(null, null, null, null); + } + + /** + * Creates a new OpenAiSdkEmbeddingModel with the given options. + * @param options the embedding options + */ + public OpenAiSdkEmbeddingModel(OpenAiSdkEmbeddingOptions options) { + this(null, null, options, null); + } + + /** + * Creates a new OpenAiSdkEmbeddingModel with the given metadata mode and options. + * @param metadataMode the metadata mode + * @param options the embedding options + */ + public OpenAiSdkEmbeddingModel(MetadataMode metadataMode, OpenAiSdkEmbeddingOptions options) { + this(null, metadataMode, options, null); + } + + /** + * Creates a new OpenAiSdkEmbeddingModel with the given options and observation + * registry. + * @param options the embedding options + * @param observationRegistry the observation registry + */ + public OpenAiSdkEmbeddingModel(OpenAiSdkEmbeddingOptions options, ObservationRegistry observationRegistry) { + this(null, null, options, observationRegistry); + } + + /** + * Creates a new OpenAiSdkEmbeddingModel with the given metadata mode, options, and + * observation registry. + * @param metadataMode the metadata mode + * @param options the embedding options + * @param observationRegistry the observation registry + */ + public OpenAiSdkEmbeddingModel(MetadataMode metadataMode, OpenAiSdkEmbeddingOptions options, + ObservationRegistry observationRegistry) { + this(null, metadataMode, options, observationRegistry); + } + + /** + * Creates a new OpenAiSdkEmbeddingModel with the given OpenAI client. + * @param openAiClient the OpenAI client + */ + public OpenAiSdkEmbeddingModel(OpenAIClient openAiClient) { + this(openAiClient, null, null, null); + } + + /** + * Creates a new OpenAiSdkEmbeddingModel with the given OpenAI client and metadata + * mode. + * @param openAiClient the OpenAI client + * @param metadataMode the metadata mode + */ + public OpenAiSdkEmbeddingModel(OpenAIClient openAiClient, MetadataMode metadataMode) { + this(openAiClient, metadataMode, null, null); + } + + /** + * Creates a new OpenAiSdkEmbeddingModel with all configuration options. + * @param openAiClient the OpenAI client + * @param metadataMode the metadata mode + * @param options the embedding options + */ + public OpenAiSdkEmbeddingModel(OpenAIClient openAiClient, MetadataMode metadataMode, + OpenAiSdkEmbeddingOptions options) { + this(openAiClient, metadataMode, options, null); + } + + /** + * Creates a new OpenAiSdkEmbeddingModel with all configuration options. + * @param openAiClient the OpenAI client + * @param metadataMode the metadata mode + * @param options the embedding options + * @param observationRegistry the observation registry + */ + public OpenAiSdkEmbeddingModel(OpenAIClient openAiClient, MetadataMode metadataMode, + OpenAiSdkEmbeddingOptions options, ObservationRegistry observationRegistry) { + + if (options == null) { + this.options = OpenAiSdkEmbeddingOptions.builder().model(DEFAULT_MODEL_NAME).build(); + } + else { + this.options = options; + } + this.openAiClient = Objects.requireNonNullElseGet(openAiClient, + () -> OpenAiSdkSetup.setupSyncClient(this.options.getBaseUrl(), this.options.getApiKey(), + this.options.getCredential(), this.options.getMicrosoftDeploymentName(), + this.options.getMicrosoftFoundryServiceVersion(), this.options.getOrganizationId(), + this.options.isMicrosoftFoundry(), this.options.isGitHubModels(), this.options.getModel(), + this.options.getTimeout(), this.options.getMaxRetries(), this.options.getProxy(), + this.options.getCustomHeaders())); + this.metadataMode = Objects.requireNonNullElse(metadataMode, MetadataMode.EMBED); + this.observationRegistry = Objects.requireNonNullElse(observationRegistry, ObservationRegistry.NOOP); + } + + @Override + public float[] embed(Document document) { + EmbeddingResponse response = this + .call(new EmbeddingRequest(List.of(document.getFormattedContent(this.metadataMode)), null)); + + if (CollectionUtils.isEmpty(response.getResults())) { + return new float[0]; + } + return response.getResults().get(0).getOutput(); + } + + @Override + public EmbeddingResponse call(EmbeddingRequest embeddingRequest) { + OpenAiSdkEmbeddingOptions options = OpenAiSdkEmbeddingOptions.builder() + .from(this.options) + .merge(embeddingRequest.getOptions()) + .build(); + + EmbeddingRequest embeddingRequestWithMergedOptions = new EmbeddingRequest(embeddingRequest.getInstructions(), + options); + + EmbeddingCreateParams embeddingCreateParams = options + .toOpenAiCreateParams(embeddingRequestWithMergedOptions.getInstructions()); + + if (logger.isTraceEnabled()) { + logger.trace("OpenAiSdkEmbeddingModel call {} with the following options : {} ", options.getModel(), + embeddingCreateParams); + } + + var observationContext = EmbeddingModelObservationContext.builder() + .embeddingRequest(embeddingRequestWithMergedOptions) + .provider(AiProvider.OPENAI_SDK.value()) + .build(); + + return Objects.requireNonNull( + EmbeddingModelObservationDocumentation.EMBEDDING_MODEL_OPERATION + .observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, + this.observationRegistry) + .observe(() -> { + CreateEmbeddingResponse response = this.openAiClient.embeddings().create(embeddingCreateParams); + + var embeddingResponse = generateEmbeddingResponse(response); + observationContext.setResponse(embeddingResponse); + return embeddingResponse; + })); + } + + private EmbeddingResponse generateEmbeddingResponse(CreateEmbeddingResponse response) { + + List data = generateEmbeddingList(response.data()); + EmbeddingResponseMetadata metadata = new EmbeddingResponseMetadata(); + metadata.setModel(response.model()); + metadata.setUsage(getDefaultUsage(response.usage())); + return new EmbeddingResponse(data, metadata); + } + + private DefaultUsage getDefaultUsage(CreateEmbeddingResponse.Usage nativeUsage) { + return new DefaultUsage(Math.toIntExact(nativeUsage.promptTokens()), 0, + Math.toIntExact(nativeUsage.totalTokens()), nativeUsage); + } + + private List generateEmbeddingList(List nativeData) { + List data = new ArrayList<>(); + for (com.openai.models.embeddings.Embedding nativeDatum : nativeData) { + List nativeDatumEmbedding = nativeDatum.embedding(); + long nativeIndex = nativeDatum.index(); + Embedding embedding = new Embedding(EmbeddingUtils.toPrimitive(nativeDatumEmbedding), + Math.toIntExact(nativeIndex)); + data.add(embedding); + } + return data; + } + + /** + * Gets the embedding options for this model. + * @return the embedding options + */ + public OpenAiSdkEmbeddingOptions getOptions() { + return this.options; + } + + /** + * Use the provided convention for reporting observation data + * @param observationConvention The provided convention + */ + public void setObservationConvention(EmbeddingModelObservationConvention observationConvention) { + Assert.notNull(observationConvention, "observationConvention cannot be null"); + this.observationConvention = observationConvention; + } + +} diff --git a/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/OpenAiSdkEmbeddingOptions.java b/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/OpenAiSdkEmbeddingOptions.java new file mode 100644 index 00000000000..d3b73550700 --- /dev/null +++ b/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/OpenAiSdkEmbeddingOptions.java @@ -0,0 +1,266 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.openaisdk; + +import java.util.List; + +import com.openai.models.embeddings.EmbeddingCreateParams; +import com.openai.models.embeddings.EmbeddingModel; + +import org.springframework.ai.embedding.EmbeddingOptions; + +/** + * Configuration information for the Embedding Model implementation using the OpenAI Java + * SDK. + * + * @author Julien Dubois + */ +public class OpenAiSdkEmbeddingOptions extends AbstractOpenAiSdkOptions implements EmbeddingOptions { + + public static final String DEFAULT_EMBEDDING_MODEL = EmbeddingModel.TEXT_EMBEDDING_ADA_002.asString(); + + /** + * An identifier for the caller or end user of the operation. This may be used for + * tracking or rate-limiting purposes. + */ + private String user; + + /* + * The number of dimensions the resulting output embeddings should have. Only + * supported in `text-embedding-3` and later models. + */ + private Integer dimensions; + + public static Builder builder() { + return new Builder(); + } + + public String getUser() { + return this.user; + } + + public void setUser(String user) { + this.user = user; + } + + @Override + public Integer getDimensions() { + return this.dimensions; + } + + public void setDimensions(Integer dimensions) { + this.dimensions = dimensions; + } + + @Override + public String toString() { + return "OpenAiSdkEmbeddingOptions{" + "user='" + this.user + '\'' + ", model='" + this.getModel() + '\'' + + ", deploymentName='" + this.getDeploymentName() + '\'' + ", dimensions=" + this.dimensions + '}'; + } + + public EmbeddingCreateParams toOpenAiCreateParams(List instructions) { + + EmbeddingCreateParams.Builder builder = EmbeddingCreateParams.builder(); + + // Use deployment name if available (for Microsoft Foundry), otherwise use model + // name + if (this.getDeploymentName() != null) { + builder.model(this.getDeploymentName()); + } + else if (this.getModel() != null) { + builder.model(this.getModel()); + } + + if (instructions != null && !instructions.isEmpty()) { + builder.input(EmbeddingCreateParams.Input.ofArrayOfStrings(instructions)); + } + if (this.getUser() != null) { + builder.user(this.getUser()); + } + if (this.getDimensions() != null) { + builder.dimensions(this.getDimensions()); + } + return builder.build(); + } + + public static final class Builder { + + private final OpenAiSdkEmbeddingOptions options = new OpenAiSdkEmbeddingOptions(); + + public Builder from(OpenAiSdkEmbeddingOptions fromOptions) { + // Parent class fields + this.options.setBaseUrl(fromOptions.getBaseUrl()); + this.options.setApiKey(fromOptions.getApiKey()); + this.options.setCredential(fromOptions.getCredential()); + this.options.setModel(fromOptions.getModel()); + this.options.setDeploymentName(fromOptions.getDeploymentName()); + this.options.setMicrosoftFoundryServiceVersion(fromOptions.getMicrosoftFoundryServiceVersion()); + this.options.setOrganizationId(fromOptions.getOrganizationId()); + this.options.setMicrosoftFoundry(fromOptions.isMicrosoftFoundry()); + this.options.setGitHubModels(fromOptions.isGitHubModels()); + this.options.setTimeout(fromOptions.getTimeout()); + this.options.setMaxRetries(fromOptions.getMaxRetries()); + this.options.setProxy(fromOptions.getProxy()); + this.options.setCustomHeaders(fromOptions.getCustomHeaders()); + // Child class fields + this.options.setUser(fromOptions.getUser()); + this.options.setDimensions(fromOptions.getDimensions()); + return this; + } + + public Builder merge(EmbeddingOptions from) { + if (from instanceof OpenAiSdkEmbeddingOptions castFrom) { + // Parent class fields + if (castFrom.getBaseUrl() != null) { + this.options.setBaseUrl(castFrom.getBaseUrl()); + } + if (castFrom.getApiKey() != null) { + this.options.setApiKey(castFrom.getApiKey()); + } + if (castFrom.getCredential() != null) { + this.options.setCredential(castFrom.getCredential()); + } + if (castFrom.getModel() != null) { + this.options.setModel(castFrom.getModel()); + } + if (castFrom.getDeploymentName() != null) { + this.options.setDeploymentName(castFrom.getDeploymentName()); + } + if (castFrom.getMicrosoftFoundryServiceVersion() != null) { + this.options.setMicrosoftFoundryServiceVersion(castFrom.getMicrosoftFoundryServiceVersion()); + } + if (castFrom.getOrganizationId() != null) { + this.options.setOrganizationId(castFrom.getOrganizationId()); + } + this.options.setMicrosoftFoundry(castFrom.isMicrosoftFoundry()); + this.options.setGitHubModels(castFrom.isGitHubModels()); + if (castFrom.getTimeout() != null) { + this.options.setTimeout(castFrom.getTimeout()); + } + if (castFrom.getMaxRetries() != null) { + this.options.setMaxRetries(castFrom.getMaxRetries()); + } + if (castFrom.getProxy() != null) { + this.options.setProxy(castFrom.getProxy()); + } + if (castFrom.getCustomHeaders() != null) { + this.options.setCustomHeaders(castFrom.getCustomHeaders()); + } + // Child class fields + if (castFrom.getUser() != null) { + this.options.setUser(castFrom.getUser()); + } + if (castFrom.getDimensions() != null) { + this.options.setDimensions(castFrom.getDimensions()); + } + } + return this; + } + + public Builder from(EmbeddingCreateParams openAiCreateParams) { + + if (openAiCreateParams.user().isPresent()) { + this.options.setUser(openAiCreateParams.user().get()); + } + if (openAiCreateParams.dimensions().isPresent()) { + this.options.setDimensions(Math.toIntExact(openAiCreateParams.dimensions().get())); + } + return this; + } + + public Builder user(String user) { + this.options.setUser(user); + return this; + } + + public Builder deploymentName(String deploymentName) { + this.options.setDeploymentName(deploymentName); + return this; + } + + public Builder model(String model) { + this.options.setModel(model); + return this; + } + + public Builder baseUrl(String baseUrl) { + this.options.setBaseUrl(baseUrl); + return this; + } + + public Builder apiKey(String apiKey) { + this.options.setApiKey(apiKey); + return this; + } + + public Builder credential(com.openai.credential.Credential credential) { + this.options.setCredential(credential); + return this; + } + + public Builder azureOpenAIServiceVersion(com.openai.azure.AzureOpenAIServiceVersion azureOpenAIServiceVersion) { + this.options.setMicrosoftFoundryServiceVersion(azureOpenAIServiceVersion); + return this; + } + + public Builder organizationId(String organizationId) { + this.options.setOrganizationId(organizationId); + return this; + } + + public Builder azure(boolean azure) { + this.options.setMicrosoftFoundry(azure); + return this; + } + + public Builder gitHubModels(boolean gitHubModels) { + this.options.setGitHubModels(gitHubModels); + return this; + } + + public Builder timeout(java.time.Duration timeout) { + this.options.setTimeout(timeout); + return this; + } + + public Builder maxRetries(Integer maxRetries) { + this.options.setMaxRetries(maxRetries); + return this; + } + + public Builder proxy(java.net.Proxy proxy) { + this.options.setProxy(proxy); + return this; + } + + public Builder customHeaders(java.util.Map customHeaders) { + this.options.setCustomHeaders(customHeaders); + return this; + } + + public Builder dimensions(Integer dimensions) { + this.options.dimensions = dimensions; + return this; + } + + public OpenAiSdkEmbeddingOptions build() { + return this.options; + } + + } + +} diff --git a/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/OpenAiSdkImageModel.java b/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/OpenAiSdkImageModel.java new file mode 100644 index 00000000000..1bb52fb15dd --- /dev/null +++ b/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/OpenAiSdkImageModel.java @@ -0,0 +1,218 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.openaisdk; + +import java.util.List; +import java.util.Objects; + +import com.openai.client.OpenAIClient; +import com.openai.models.images.ImageGenerateParams; +import io.micrometer.observation.ObservationRegistry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.image.Image; +import org.springframework.ai.image.ImageGeneration; +import org.springframework.ai.image.ImageModel; +import org.springframework.ai.image.ImagePrompt; +import org.springframework.ai.image.ImageResponse; +import org.springframework.ai.image.ImageResponseMetadata; +import org.springframework.ai.image.observation.DefaultImageModelObservationConvention; +import org.springframework.ai.image.observation.ImageModelObservationContext; +import org.springframework.ai.image.observation.ImageModelObservationConvention; +import org.springframework.ai.image.observation.ImageModelObservationDocumentation; +import org.springframework.ai.observation.conventions.AiProvider; +import org.springframework.ai.openaisdk.metadata.OpenAiSdkImageGenerationMetadata; +import org.springframework.ai.openaisdk.metadata.OpenAiSdkImageResponseMetadata; +import org.springframework.ai.openaisdk.setup.OpenAiSdkSetup; +import org.springframework.util.Assert; + +/** + * Image Model implementation using the OpenAI Java SDK. + * + * @author Julien Dubois + */ +public class OpenAiSdkImageModel implements ImageModel { + + private static final String DEFAULT_MODEL_NAME = OpenAiSdkImageOptions.DEFAULT_IMAGE_MODEL; + + private static final ImageModelObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultImageModelObservationConvention(); + + private final Logger logger = LoggerFactory.getLogger(OpenAiSdkImageModel.class); + + private final OpenAIClient openAiClient; + + private final OpenAiSdkImageOptions options; + + private final ObservationRegistry observationRegistry; + + private ImageModelObservationConvention observationConvention = DEFAULT_OBSERVATION_CONVENTION; + + /** + * Creates a new OpenAiSdkImageModel with default options. + */ + public OpenAiSdkImageModel() { + this(null, null, null); + } + + /** + * Creates a new OpenAiSdkImageModel with the given options. + * @param options the image options + */ + public OpenAiSdkImageModel(OpenAiSdkImageOptions options) { + this(null, options, null); + } + + /** + * Creates a new OpenAiSdkImageModel with the given observation registry. + * @param observationRegistry the observation registry + */ + public OpenAiSdkImageModel(ObservationRegistry observationRegistry) { + this(null, null, observationRegistry); + } + + /** + * Creates a new OpenAiSdkImageModel with the given options and observation registry. + * @param options the image options + * @param observationRegistry the observation registry + */ + public OpenAiSdkImageModel(OpenAiSdkImageOptions options, ObservationRegistry observationRegistry) { + this(null, options, observationRegistry); + } + + /** + * Creates a new OpenAiSdkImageModel with the given OpenAI client. + * @param openAIClient the OpenAI client + */ + public OpenAiSdkImageModel(OpenAIClient openAIClient) { + this(openAIClient, null, null); + } + + /** + * Creates a new OpenAiSdkImageModel with the given OpenAI client and options. + * @param openAIClient the OpenAI client + * @param options the image options + */ + public OpenAiSdkImageModel(OpenAIClient openAIClient, OpenAiSdkImageOptions options) { + this(openAIClient, options, null); + } + + /** + * Creates a new OpenAiSdkImageModel with the given OpenAI client and observation + * registry. + * @param openAIClient the OpenAI client + * @param observationRegistry the observation registry + */ + public OpenAiSdkImageModel(OpenAIClient openAIClient, ObservationRegistry observationRegistry) { + this(openAIClient, null, observationRegistry); + } + + /** + * Creates a new OpenAiSdkImageModel with all configuration options. + * @param openAiClient the OpenAI client + * @param options the image options + * @param observationRegistry the observation registry + */ + public OpenAiSdkImageModel(OpenAIClient openAiClient, OpenAiSdkImageOptions options, + ObservationRegistry observationRegistry) { + + if (options == null) { + this.options = OpenAiSdkImageOptions.builder().model(DEFAULT_MODEL_NAME).build(); + } + else { + this.options = options; + } + this.openAiClient = Objects.requireNonNullElseGet(openAiClient, + () -> OpenAiSdkSetup.setupSyncClient(this.options.getBaseUrl(), this.options.getApiKey(), + this.options.getCredential(), this.options.getMicrosoftDeploymentName(), + this.options.getMicrosoftFoundryServiceVersion(), this.options.getOrganizationId(), + this.options.isMicrosoftFoundry(), this.options.isGitHubModels(), this.options.getModel(), + this.options.getTimeout(), this.options.getMaxRetries(), this.options.getProxy(), + this.options.getCustomHeaders())); + this.observationRegistry = Objects.requireNonNullElse(observationRegistry, ObservationRegistry.NOOP); + } + + /** + * Gets the image options for this model. + * @return the image options + */ + public OpenAiSdkImageOptions getOptions() { + return this.options; + } + + @Override + public ImageResponse call(ImagePrompt imagePrompt) { + OpenAiSdkImageOptions options = OpenAiSdkImageOptions.builder() + .from(this.options) + .merge(imagePrompt.getOptions()) + .build(); + + ImageGenerateParams imageGenerateParams = options.toOpenAiImageGenerateParams(imagePrompt); + + if (logger.isTraceEnabled()) { + logger.trace("OpenAiSdkImageOptions call {} with the following options : {} ", options.getModel(), + imageGenerateParams); + } + + var observationContext = ImageModelObservationContext.builder() + .imagePrompt(imagePrompt) + .provider(AiProvider.OPENAI_SDK.value()) + .build(); + + return Objects.requireNonNull( + ImageModelObservationDocumentation.IMAGE_MODEL_OPERATION + .observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, + this.observationRegistry) + .observe(() -> { + var images = this.openAiClient.images().generate(imageGenerateParams); + + if (images.data().isEmpty() && images.data().get().isEmpty()) { + throw new IllegalArgumentException("Image generation failed: no image returned"); + } + + List imageGenerations = images.data().get().stream().map(nativeImage -> { + Image image; + if (nativeImage.url().isPresent()) { + image = new Image(nativeImage.url().get(), null); + } + else if (nativeImage.b64Json().isPresent()) { + image = new Image(null, nativeImage.b64Json().get()); + } + else { + throw new IllegalArgumentException( + "Image generation failed: image entry missing url and b64_json"); + } + var metadata = new OpenAiSdkImageGenerationMetadata(nativeImage.revisedPrompt()); + return new ImageGeneration(image, metadata); + }).toList(); + ImageResponseMetadata openAiImageResponseMetadata = OpenAiSdkImageResponseMetadata.from(images); + ImageResponse imageResponse = new ImageResponse(imageGenerations, openAiImageResponseMetadata); + observationContext.setResponse(imageResponse); + return imageResponse; + })); + } + + /** + * Use the provided convention for reporting observation data + * @param observationConvention The provided convention + */ + public void setObservationConvention(ImageModelObservationConvention observationConvention) { + Assert.notNull(observationConvention, "observationConvention cannot be null"); + this.observationConvention = observationConvention; + } + +} diff --git a/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/OpenAiSdkImageOptions.java b/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/OpenAiSdkImageOptions.java new file mode 100644 index 00000000000..711c1e2e9a5 --- /dev/null +++ b/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/OpenAiSdkImageOptions.java @@ -0,0 +1,430 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.openaisdk; + +import java.util.Objects; + +import com.openai.models.images.ImageGenerateParams; +import com.openai.models.images.ImageModel; + +import org.springframework.ai.image.ImageOptions; +import org.springframework.ai.image.ImagePrompt; + +/** + * Configuration information for the Image Model implementation using the OpenAI Java SDK. + * + * @author Julien Dubois + */ +public class OpenAiSdkImageOptions extends AbstractOpenAiSdkOptions implements ImageOptions { + + public static final String DEFAULT_IMAGE_MODEL = ImageModel.DALL_E_3.toString(); + + /** + * The number of images to generate. Must be between 1 and 10. For dall-e-3, only n=1 + * is supported. + */ + private Integer n; + + /** + * The width of the generated images. Must be one of 256, 512, or 1024 for dall-e-2. + */ + private Integer width; + + /** + * The height of the generated images. Must be one of 256, 512, or 1024 for dall-e-2. + */ + private Integer height; + + /** + * The quality of the image that will be generated. hd creates images with finer + * details and greater consistency across the image. This param is only supported for + * dall-e-3. standard or hd + */ + private String quality; + + /** + * The format in which the generated images are returned. Must be one of url or + * b64_json. + */ + private String responseFormat; + + /** + * The size of the generated images. Must be one of 256x256, 512x512, or 1024x1024 for + * dall-e-2. Must be one of 1024x1024, 1792x1024, or 1024x1792 for dall-e-3 models. + */ + private String size; + + /** + * The style of the generated images. Must be one of vivid or natural. Vivid causes + * the model to lean towards generating hyper-real and dramatic images. Natural causes + * the model to produce more natural, less hyper-real looking images. This param is + * only supported for dall-e-3. natural or vivid + */ + private String style; + + /** + * A unique identifier representing your end-user, which can help OpenAI to monitor + * and detect abuse. + */ + private String user; + + public static Builder builder() { + return new Builder(); + } + + @Override + public Integer getN() { + return this.n; + } + + public void setN(Integer n) { + this.n = n; + } + + @Override + public Integer getWidth() { + return this.width; + } + + public void setWidth(Integer width) { + this.width = width; + this.size = this.width + "x" + this.height; + } + + @Override + public Integer getHeight() { + return this.height; + } + + public void setHeight(Integer height) { + this.height = height; + this.size = this.width + "x" + this.height; + } + + @Override + public String getResponseFormat() { + return this.responseFormat; + } + + public void setResponseFormat(String responseFormat) { + this.responseFormat = responseFormat; + } + + public String getSize() { + if (this.size != null) { + return this.size; + } + return (this.width != null && this.height != null) ? this.width + "x" + this.height : null; + } + + public void setSize(String size) { + this.size = size; + } + + public String getUser() { + return this.user; + } + + public void setUser(String user) { + this.user = user; + } + + public String getQuality() { + return this.quality; + } + + public void setQuality(String quality) { + this.quality = quality; + } + + @Override + public String getStyle() { + return this.style; + } + + public void setStyle(String style) { + this.style = style; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + OpenAiSdkImageOptions that = (OpenAiSdkImageOptions) o; + return Objects.equals(this.n, that.n) && Objects.equals(this.width, that.width) + && Objects.equals(this.height, that.height) && Objects.equals(this.quality, that.quality) + && Objects.equals(this.responseFormat, that.responseFormat) && Objects.equals(this.size, that.size) + && Objects.equals(this.style, that.style) && Objects.equals(this.user, that.user); + } + + @Override + public int hashCode() { + return Objects.hash(this.n, this.width, this.height, this.quality, this.responseFormat, this.size, this.style, + this.user); + } + + @Override + public String toString() { + return "OpenAiSdkImageOptions{" + "n=" + this.n + ", width=" + this.width + ", height=" + this.height + + ", quality='" + this.quality + '\'' + ", responseFormat='" + this.responseFormat + '\'' + ", size='" + + this.size + '\'' + ", style='" + this.style + '\'' + ", user='" + this.user + '\'' + '}'; + } + + public ImageGenerateParams toOpenAiImageGenerateParams(ImagePrompt imagePrompt) { + if (imagePrompt.getInstructions().isEmpty()) { + throw new IllegalArgumentException("Image prompt instructions cannot be empty"); + } + + String prompt = imagePrompt.getInstructions().get(0).getText(); + ImageGenerateParams.Builder builder = ImageGenerateParams.builder().prompt(prompt); + + // Use deployment name if available (for Microsoft Foundry), otherwise use model + // name + if (this.getDeploymentName() != null) { + builder.model(this.getDeploymentName()); + } + else if (this.getModel() != null) { + builder.model(this.getModel()); + } + + if (this.getN() != null) { + builder.n(this.getN().longValue()); + } + if (this.getQuality() != null) { + builder.quality(ImageGenerateParams.Quality.of(this.getQuality().toLowerCase())); + } + if (this.getResponseFormat() != null) { + builder.responseFormat(ImageGenerateParams.ResponseFormat.of(this.getResponseFormat().toLowerCase())); + } + if (this.getSize() != null) { + builder.size(ImageGenerateParams.Size.of(this.getSize())); + } + if (this.getStyle() != null) { + builder.style(ImageGenerateParams.Style.of(this.getStyle().toLowerCase())); + } + if (this.getUser() != null) { + builder.user(this.getUser()); + } + + return builder.build(); + } + + public static final class Builder { + + private final OpenAiSdkImageOptions options; + + private Builder() { + this.options = new OpenAiSdkImageOptions(); + } + + public Builder from(OpenAiSdkImageOptions fromOptions) { + // Parent class fields + this.options.setBaseUrl(fromOptions.getBaseUrl()); + this.options.setApiKey(fromOptions.getApiKey()); + this.options.setCredential(fromOptions.getCredential()); + this.options.setModel(fromOptions.getModel()); + this.options.setDeploymentName(fromOptions.getDeploymentName()); + this.options.setMicrosoftFoundryServiceVersion(fromOptions.getMicrosoftFoundryServiceVersion()); + this.options.setOrganizationId(fromOptions.getOrganizationId()); + this.options.setMicrosoftFoundry(fromOptions.isMicrosoftFoundry()); + this.options.setGitHubModels(fromOptions.isGitHubModels()); + this.options.setTimeout(fromOptions.getTimeout()); + this.options.setMaxRetries(fromOptions.getMaxRetries()); + this.options.setProxy(fromOptions.getProxy()); + this.options.setCustomHeaders(fromOptions.getCustomHeaders()); + // Child class fields + this.options.setN(fromOptions.getN()); + this.options.setWidth(fromOptions.getWidth()); + this.options.setHeight(fromOptions.getHeight()); + this.options.setQuality(fromOptions.getQuality()); + this.options.setResponseFormat(fromOptions.getResponseFormat()); + this.options.setSize(fromOptions.getSize()); + this.options.setStyle(fromOptions.getStyle()); + this.options.setUser(fromOptions.getUser()); + return this; + } + + public Builder merge(ImageOptions from) { + if (from instanceof OpenAiSdkImageOptions castFrom) { + // Parent class fields + if (castFrom.getBaseUrl() != null) { + this.options.setBaseUrl(castFrom.getBaseUrl()); + } + if (castFrom.getApiKey() != null) { + this.options.setApiKey(castFrom.getApiKey()); + } + if (castFrom.getCredential() != null) { + this.options.setCredential(castFrom.getCredential()); + } + if (castFrom.getModel() != null) { + this.options.setModel(castFrom.getModel()); + } + if (castFrom.getDeploymentName() != null) { + this.options.setDeploymentName(castFrom.getDeploymentName()); + } + if (castFrom.getMicrosoftFoundryServiceVersion() != null) { + this.options.setMicrosoftFoundryServiceVersion(castFrom.getMicrosoftFoundryServiceVersion()); + } + if (castFrom.getOrganizationId() != null) { + this.options.setOrganizationId(castFrom.getOrganizationId()); + } + this.options.setMicrosoftFoundry(castFrom.isMicrosoftFoundry()); + this.options.setGitHubModels(castFrom.isGitHubModels()); + if (castFrom.getTimeout() != null) { + this.options.setTimeout(castFrom.getTimeout()); + } + if (castFrom.getMaxRetries() != null) { + this.options.setMaxRetries(castFrom.getMaxRetries()); + } + if (castFrom.getProxy() != null) { + this.options.setProxy(castFrom.getProxy()); + } + if (castFrom.getCustomHeaders() != null) { + this.options.setCustomHeaders(castFrom.getCustomHeaders()); + } + // Child class fields + if (castFrom.getN() != null) { + this.options.setN(castFrom.getN()); + } + if (castFrom.getWidth() != null) { + this.options.setWidth(castFrom.getWidth()); + } + if (castFrom.getHeight() != null) { + this.options.setHeight(castFrom.getHeight()); + } + if (castFrom.getQuality() != null) { + this.options.setQuality(castFrom.getQuality()); + } + if (castFrom.getResponseFormat() != null) { + this.options.setResponseFormat(castFrom.getResponseFormat()); + } + if (castFrom.getSize() != null) { + this.options.setSize(castFrom.getSize()); + } + if (castFrom.getStyle() != null) { + this.options.setStyle(castFrom.getStyle()); + } + if (castFrom.getUser() != null) { + this.options.setUser(castFrom.getUser()); + } + } + return this; + } + + public Builder N(Integer n) { + this.options.setN(n); + return this; + } + + public Builder model(String model) { + this.options.setModel(model); + return this; + } + + public Builder deploymentName(String deploymentName) { + this.options.setDeploymentName(deploymentName); + return this; + } + + public Builder baseUrl(String baseUrl) { + this.options.setBaseUrl(baseUrl); + return this; + } + + public Builder apiKey(String apiKey) { + this.options.setApiKey(apiKey); + return this; + } + + public Builder credential(com.openai.credential.Credential credential) { + this.options.setCredential(credential); + return this; + } + + public Builder azureOpenAIServiceVersion(com.openai.azure.AzureOpenAIServiceVersion azureOpenAIServiceVersion) { + this.options.setMicrosoftFoundryServiceVersion(azureOpenAIServiceVersion); + return this; + } + + public Builder organizationId(String organizationId) { + this.options.setOrganizationId(organizationId); + return this; + } + + public Builder azure(boolean azure) { + this.options.setMicrosoftFoundry(azure); + return this; + } + + public Builder gitHubModels(boolean gitHubModels) { + this.options.setGitHubModels(gitHubModels); + return this; + } + + public Builder timeout(java.time.Duration timeout) { + this.options.setTimeout(timeout); + return this; + } + + public Builder maxRetries(Integer maxRetries) { + this.options.setMaxRetries(maxRetries); + return this; + } + + public Builder proxy(java.net.Proxy proxy) { + this.options.setProxy(proxy); + return this; + } + + public Builder customHeaders(java.util.Map customHeaders) { + this.options.setCustomHeaders(customHeaders); + return this; + } + + public Builder responseFormat(String responseFormat) { + this.options.setResponseFormat(responseFormat); + return this; + } + + public Builder width(Integer width) { + this.options.setWidth(width); + return this; + } + + public Builder height(Integer height) { + this.options.setHeight(height); + return this; + } + + public Builder user(String user) { + this.options.setUser(user); + return this; + } + + public Builder style(String style) { + this.options.setStyle(style); + return this; + } + + public OpenAiSdkImageOptions build() { + return this.options; + } + + } + +} diff --git a/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/metadata/OpenAiSdkImageGenerationMetadata.java b/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/metadata/OpenAiSdkImageGenerationMetadata.java new file mode 100644 index 00000000000..2cce8755992 --- /dev/null +++ b/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/metadata/OpenAiSdkImageGenerationMetadata.java @@ -0,0 +1,75 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.openaisdk.metadata; + +import java.util.Objects; +import java.util.Optional; + +import org.springframework.ai.image.ImageGenerationMetadata; + +/** + * Represents the metadata for image generation using the OpenAI Java SDK. + * + * @author Julien Dubois + */ +public class OpenAiSdkImageGenerationMetadata implements ImageGenerationMetadata { + + private final String revisedPrompt; + + /** + * Creates a new OpenAiSdkImageGenerationMetadata. + * @param revisedPrompt the revised prompt used for generation + */ + public OpenAiSdkImageGenerationMetadata(Optional revisedPrompt) { + if (revisedPrompt.isPresent()) { + this.revisedPrompt = revisedPrompt.get(); + } + else { + this.revisedPrompt = null; + } + } + + /** + * Gets the revised prompt that was used for image generation. + * @return the revised prompt, or null if not available + */ + public String getRevisedPrompt() { + return this.revisedPrompt; + } + + @Override + public String toString() { + return "OpenAiSdkImageGenerationMetadata{" + "revisedPrompt='" + this.revisedPrompt + '\'' + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof OpenAiSdkImageGenerationMetadata that)) { + return false; + } + return Objects.equals(this.revisedPrompt, that.revisedPrompt); + } + + @Override + public int hashCode() { + return Objects.hash(this.revisedPrompt); + } + +} diff --git a/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/metadata/OpenAiSdkImageResponseMetadata.java b/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/metadata/OpenAiSdkImageResponseMetadata.java new file mode 100644 index 00000000000..1ad635d0a47 --- /dev/null +++ b/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/metadata/OpenAiSdkImageResponseMetadata.java @@ -0,0 +1,79 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.openaisdk.metadata; + +import java.util.Objects; + +import com.openai.models.images.ImagesResponse; + +import org.springframework.ai.image.ImageResponseMetadata; +import org.springframework.util.Assert; + +/** + * Represents the metadata for image response using the OpenAI Java SDK. + * + * @author Julien Dubois + */ +public class OpenAiSdkImageResponseMetadata extends ImageResponseMetadata { + + private final Long created; + + /** + * Creates a new OpenAiSdkImageResponseMetadata. + * @param created the creation timestamp + */ + protected OpenAiSdkImageResponseMetadata(Long created) { + this.created = created; + } + + /** + * Creates metadata from an ImagesResponse. + * @param imagesResponse the OpenAI images response + * @return the metadata instance + */ + public static OpenAiSdkImageResponseMetadata from(ImagesResponse imagesResponse) { + Assert.notNull(imagesResponse, "imagesResponse must not be null"); + return new OpenAiSdkImageResponseMetadata(imagesResponse.created()); + } + + @Override + public Long getCreated() { + return this.created; + } + + @Override + public String toString() { + return "OpenAiSdkImageResponseMetadata{" + "created=" + this.created + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof OpenAiSdkImageResponseMetadata that)) { + return false; + } + return Objects.equals(this.created, that.created); + } + + @Override + public int hashCode() { + return Objects.hash(this.created); + } + +} diff --git a/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/metadata/package-info.java b/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/metadata/package-info.java new file mode 100644 index 00000000000..e33867ac25e --- /dev/null +++ b/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/metadata/package-info.java @@ -0,0 +1,25 @@ +/* + * 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. + */ + +/** + * Metadata classes for OpenAI SDK model responses. + *

+ * This package contains metadata implementations for chat, embedding, and image model + * responses. + * + * @author Julien Dubois + */ +package org.springframework.ai.openaisdk.metadata; diff --git a/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/package-info.java b/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/package-info.java new file mode 100644 index 00000000000..b52b885c4c0 --- /dev/null +++ b/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/package-info.java @@ -0,0 +1,25 @@ +/* + * 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. + */ + +/** + * Spring AI integration with the official OpenAI Java SDK. + *

+ * This package provides chat, embedding, and image model implementations using the OpenAI + * Java SDK client library. + * + * @author Julien Dubois + */ +package org.springframework.ai.openaisdk; diff --git a/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/setup/AzureInternalOpenAiSdkHelper.java b/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/setup/AzureInternalOpenAiSdkHelper.java new file mode 100644 index 00000000000..ab772a5ffaf --- /dev/null +++ b/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/setup/AzureInternalOpenAiSdkHelper.java @@ -0,0 +1,44 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.openaisdk.setup; + +import com.azure.identity.AuthenticationUtil; +import com.azure.identity.DefaultAzureCredentialBuilder; +import com.openai.credential.BearerTokenCredential; +import com.openai.credential.Credential; + +/** + * Specific configuration for authenticating on Azure. This is in a separate class to + * avoid needing the Azure SDK dependencies when not using Azure as a platform. + * + * This code is inspired by LangChain4j's + * `dev.langchain4j.model.openaiofficial.AzureInternalOpenAiOfficialHelper` class, which + * is coded by the same author (Julien Dubois, from Microsoft). + * + * @author Julien Dubois + */ +final class AzureInternalOpenAiSdkHelper { + + private AzureInternalOpenAiSdkHelper() { + } + + static Credential getAzureCredential() { + return BearerTokenCredential.create(AuthenticationUtil.getBearerTokenSupplier( + new DefaultAzureCredentialBuilder().build(), "https://cognitiveservices.azure.com/.default")); + } + +} diff --git a/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/setup/OpenAiSdkSetup.java b/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/setup/OpenAiSdkSetup.java new file mode 100644 index 00000000000..86f52a25aec --- /dev/null +++ b/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/setup/OpenAiSdkSetup.java @@ -0,0 +1,285 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.openaisdk.setup; + +import java.net.Proxy; +import java.time.Duration; +import java.util.Collections; +import java.util.Map; +import java.util.stream.Collectors; + +import com.openai.azure.AzureOpenAIServiceVersion; +import com.openai.client.OpenAIClient; +import com.openai.client.OpenAIClientAsync; +import com.openai.client.okhttp.OpenAIOkHttpClient; +import com.openai.client.okhttp.OpenAIOkHttpClientAsync; +import com.openai.credential.Credential; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Helps configure the OpenAI Java SDK, depending on the platform used. This code is + * inspired by LangChain4j's + * `dev.langchain4j.model.openaiofficial.InternalOpenAiOfficialHelper` class, which is + * coded by the same author (Julien Dubois, from Microsoft). + * + * @author Julien Dubois + */ +public final class OpenAiSdkSetup { + + static final String OPENAI_URL = "https://api.openai.com/v1"; + static final String OPENAI_API_KEY = "OPENAI_API_KEY"; + static final String AZURE_OPENAI_KEY = "AZURE_OPENAI_KEY"; + static final String GITHUB_MODELS_URL = "https://models.github.ai/inference"; + static final String GITHUB_TOKEN = "GITHUB_TOKEN"; + static final String DEFAULT_USER_AGENT = "spring-ai-openai-sdk"; + + private static final Logger logger = LoggerFactory.getLogger(OpenAiSdkSetup.class); + + private static final Duration DEFAULT_DURATION = Duration.ofSeconds(60); + + private static final int DEFAULT_MAX_RETRIES = 3; + + private OpenAiSdkSetup() { + } + + public enum ModelProvider { + + OPEN_AI, MICROSOFT_FOUNDRY, GITHUB_MODELS + + } + + public static OpenAIClient setupSyncClient(String baseUrl, String apiKey, Credential credential, + String azureDeploymentName, AzureOpenAIServiceVersion azureOpenAiServiceVersion, String organizationId, + boolean isAzure, boolean isGitHubModels, String modelName, Duration timeout, Integer maxRetries, + Proxy proxy, Map customHeaders) { + + baseUrl = detectBaseUrlFromEnv(baseUrl); + var modelProvider = detectModelProvider(isAzure, isGitHubModels, baseUrl, azureDeploymentName, + azureOpenAiServiceVersion); + if (timeout == null) { + timeout = DEFAULT_DURATION; + } + if (maxRetries == null) { + maxRetries = DEFAULT_MAX_RETRIES; + } + OpenAIOkHttpClient.Builder builder = OpenAIOkHttpClient.builder(); + builder.baseUrl(calculateBaseUrl(baseUrl, modelProvider, modelName, azureDeploymentName)); + + String calculatedApiKey = apiKey != null ? apiKey : detectApiKey(modelProvider); + if (calculatedApiKey != null) { + builder.apiKey(calculatedApiKey); + } + else { + if (credential != null) { + builder.credential(credential); + } + else if (modelProvider == ModelProvider.MICROSOFT_FOUNDRY) { + // If no API key is provided for Microsoft Foundry, we try to use + // passwordless + // authentication + builder.credential(azureAuthentication()); + } + } + builder.organization(organizationId); + + if (azureOpenAiServiceVersion != null) { + builder.azureServiceVersion(azureOpenAiServiceVersion); + } + + if (proxy != null) { + builder.proxy(proxy); + } + + builder.putHeader("User-Agent", DEFAULT_USER_AGENT); + if (customHeaders != null) { + builder.putAllHeaders(customHeaders.entrySet() + .stream() + .collect(Collectors.toMap(Map.Entry::getKey, entry -> Collections.singletonList(entry.getValue())))); + } + + builder.timeout(timeout); + builder.maxRetries(maxRetries); + return builder.build(); + } + + /** + * The asynchronous client setup is the same as the synchronous one in the OpenAI Java + * SDK, but uses a different client implementation. + */ + public static OpenAIClientAsync setupAsyncClient(String baseUrl, String apiKey, Credential credential, + String azureDeploymentName, AzureOpenAIServiceVersion azureOpenAiServiceVersion, String organizationId, + boolean isAzure, boolean isGitHubModels, String modelName, Duration timeout, Integer maxRetries, + Proxy proxy, Map customHeaders) { + + baseUrl = detectBaseUrlFromEnv(baseUrl); + var modelProvider = detectModelProvider(isAzure, isGitHubModels, baseUrl, azureDeploymentName, + azureOpenAiServiceVersion); + if (timeout == null) { + timeout = DEFAULT_DURATION; + } + if (maxRetries == null) { + maxRetries = DEFAULT_MAX_RETRIES; + } + OpenAIOkHttpClientAsync.Builder builder = OpenAIOkHttpClientAsync.builder(); + builder.baseUrl(calculateBaseUrl(baseUrl, modelProvider, modelName, azureDeploymentName)); + + String calculatedApiKey = apiKey != null ? apiKey : detectApiKey(modelProvider); + if (calculatedApiKey != null) { + builder.apiKey(calculatedApiKey); + } + else { + if (credential != null) { + builder.credential(credential); + } + else if (modelProvider == ModelProvider.MICROSOFT_FOUNDRY) { + // If no API key is provided for Microsoft Foundry, we try to use + // passwordless + // authentication + builder.credential(azureAuthentication()); + } + } + builder.organization(organizationId); + + if (azureOpenAiServiceVersion != null) { + builder.azureServiceVersion(azureOpenAiServiceVersion); + } + + if (proxy != null) { + builder.proxy(proxy); + } + + builder.putHeader("User-Agent", DEFAULT_USER_AGENT); + if (customHeaders != null) { + builder.putAllHeaders(customHeaders.entrySet() + .stream() + .collect(Collectors.toMap(Map.Entry::getKey, entry -> Collections.singletonList(entry.getValue())))); + } + + builder.timeout(timeout); + builder.maxRetries(maxRetries); + return builder.build(); + } + + static String detectBaseUrlFromEnv(String baseUrl) { + if (baseUrl == null) { + var openAiBaseUrl = System.getenv("OPENAI_BASE_URL"); + if (openAiBaseUrl != null) { + baseUrl = openAiBaseUrl; + logger.debug("OpenAI Base URL detected from environment variable OPENAI_BASE_URL."); + } + var azureOpenAiBaseUrl = System.getenv("AZURE_OPENAI_BASE_URL"); + if (azureOpenAiBaseUrl != null) { + baseUrl = azureOpenAiBaseUrl; + logger.debug("Microsoft Foundry Base URL detected from environment variable AZURE_OPENAI_BASE_URL."); + } + } + return baseUrl; + } + + public static ModelProvider detectModelProvider(boolean isAzure, boolean isGitHubModels, String baseUrl, + String azureDeploymentName, AzureOpenAIServiceVersion azureOpenAIServiceVersion) { + + if (isAzure) { + return ModelProvider.MICROSOFT_FOUNDRY; // Forced by the user + } + if (isGitHubModels) { + return ModelProvider.GITHUB_MODELS; // Forced by the user + } + if (baseUrl != null) { + if (baseUrl.endsWith("openai.azure.com") || baseUrl.endsWith("openai.azure.com/") + || baseUrl.endsWith("cognitiveservices.azure.com") + || baseUrl.endsWith("cognitiveservices.azure.com/")) { + return ModelProvider.MICROSOFT_FOUNDRY; + } + else if (baseUrl.startsWith(GITHUB_MODELS_URL)) { + return ModelProvider.GITHUB_MODELS; + } + } + if (azureDeploymentName != null || azureOpenAIServiceVersion != null) { + return ModelProvider.MICROSOFT_FOUNDRY; + } + return ModelProvider.OPEN_AI; + } + + static String calculateBaseUrl(String baseUrl, ModelProvider modelProvider, String modelName, + String azureDeploymentName) { + + if (modelProvider == ModelProvider.OPEN_AI) { + if (baseUrl == null || baseUrl.isBlank()) { + return OPENAI_URL; + } + return baseUrl; + } + else if (modelProvider == ModelProvider.GITHUB_MODELS) { + if (baseUrl == null || baseUrl.isBlank()) { + return GITHUB_MODELS_URL; + } + if (baseUrl.startsWith(GITHUB_MODELS_URL)) { + // To support GitHub Models for specific orgs + return baseUrl; + } + return GITHUB_MODELS_URL; + } + else if (modelProvider == ModelProvider.MICROSOFT_FOUNDRY) { + if (baseUrl == null || baseUrl.isBlank()) { + throw new IllegalArgumentException("Base URL must be provided for Microsoft Foundry."); + } + String tmpUrl = baseUrl; + if (baseUrl.endsWith("/") || baseUrl.endsWith("?")) { + tmpUrl = baseUrl.substring(0, baseUrl.length() - 1); + } + // If the Azure deployment name is not configured, the model name will be used + // by default by the OpenAI Java + // SDK + if (azureDeploymentName != null && !azureDeploymentName.equals(modelName)) { + tmpUrl += "/openai/deployments/" + azureDeploymentName; + } + return tmpUrl; + } + else { + throw new IllegalArgumentException("Unknown model provider: " + modelProvider); + } + } + + static Credential azureAuthentication() { + try { + return AzureInternalOpenAiSdkHelper.getAzureCredential(); + } + catch (NoClassDefFoundError e) { + throw new IllegalArgumentException("Microsoft Foundry was detected, but no credential was provided. " + + "If you want to use passwordless authentication, you need to add the Azure Identity library (groupId=`com.azure`, artifactId=`azure-identity`) to your classpath."); + } + } + + static String detectApiKey(ModelProvider modelProvider) { + if (modelProvider == ModelProvider.OPEN_AI && System.getenv(OPENAI_API_KEY) != null) { + return System.getenv(OPENAI_API_KEY); + } + else if (modelProvider == ModelProvider.MICROSOFT_FOUNDRY && System.getenv(AZURE_OPENAI_KEY) != null) { + return System.getenv(AZURE_OPENAI_KEY); + } + else if (modelProvider == ModelProvider.MICROSOFT_FOUNDRY && System.getenv(OPENAI_API_KEY) != null) { + return System.getenv(OPENAI_API_KEY); + } + else if (modelProvider == ModelProvider.GITHUB_MODELS && System.getenv(GITHUB_TOKEN) != null) { + return System.getenv(GITHUB_TOKEN); + } + return null; + } + +} diff --git a/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/setup/package-info.java b/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/setup/package-info.java new file mode 100644 index 00000000000..ee0ad2dd2fd --- /dev/null +++ b/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/setup/package-info.java @@ -0,0 +1,25 @@ +/* + * 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. + */ + +/** + * Setup and configuration utilities for OpenAI Sdk clients. + *

+ * This package contains helper classes for configuring and setting up OpenAI clients for + * different environments including OpenAI, Microsoft Foundry, and GitHub Models. + * + * @author Julien Dubois + */ +package org.springframework.ai.openaisdk.setup; diff --git a/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/OpenAiSdkTestConfiguration.java b/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/OpenAiSdkTestConfiguration.java new file mode 100644 index 00000000000..239650c969a --- /dev/null +++ b/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/OpenAiSdkTestConfiguration.java @@ -0,0 +1,45 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.openaisdk; + +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.context.annotation.Bean; + +/** + * Context configuration for OpenAI Java SDK tests. + * + * @author Julien Dubois + */ +@SpringBootConfiguration +public class OpenAiSdkTestConfiguration { + + @Bean + public OpenAiSdkEmbeddingModel openAiEmbeddingModel() { + return new OpenAiSdkEmbeddingModel(); + } + + @Bean + public OpenAiSdkImageModel openAiImageModel() { + return new OpenAiSdkImageModel(); + } + + @Bean + public OpenAiSdkChatModel openAiChatModel() { + return new OpenAiSdkChatModel(); + } + +} diff --git a/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/chat/ActorsFilms.java b/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/chat/ActorsFilms.java new file mode 100644 index 00000000000..94edde06931 --- /dev/null +++ b/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/chat/ActorsFilms.java @@ -0,0 +1,51 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.openaisdk.chat; + +import java.util.List; + +public class ActorsFilms { + + private String actor; + + private List movies; + + public ActorsFilms() { + } + + public String getActor() { + return this.actor; + } + + public void setActor(String actor) { + this.actor = actor; + } + + public List getMovies() { + return this.movies; + } + + public void setMovies(List movies) { + this.movies = movies; + } + + @Override + public String toString() { + return "ActorsFilms{" + "actor='" + this.actor + '\'' + ", movies=" + this.movies + '}'; + } + +} diff --git a/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/chat/MockWeatherService.java b/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/chat/MockWeatherService.java new file mode 100644 index 00000000000..ffdb9995eae --- /dev/null +++ b/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/chat/MockWeatherService.java @@ -0,0 +1,97 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.openaisdk.chat; + +import java.util.function.Function; + +import com.fasterxml.jackson.annotation.JsonClassDescription; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class MockWeatherService implements Function { + + private final Logger logger = LoggerFactory.getLogger(MockWeatherService.class); + + @Override + public Response apply(Request request) { + logger.info("Received weather request for location: " + request.location() + ", lat: " + request.lat() + + ", lon: " + request.lon() + ", unit: " + request.unit()); + 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, 5, 35, 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/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/chat/OpenAiSdkChatModelIT.java b/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/chat/OpenAiSdkChatModelIT.java new file mode 100644 index 00000000000..a0379a8d7e0 --- /dev/null +++ b/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/chat/OpenAiSdkChatModelIT.java @@ -0,0 +1,698 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.openaisdk.chat; + +import java.io.IOException; +import java.net.URI; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import com.openai.models.ReasoningEffort; +import org.assertj.core.data.Percentage; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.memory.ChatMemory; +import org.springframework.ai.chat.memory.MessageWindowChatMemory; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.metadata.DefaultUsage; +import org.springframework.ai.chat.metadata.EmptyUsage; +import org.springframework.ai.chat.metadata.Usage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.chat.prompt.PromptTemplate; +import org.springframework.ai.chat.prompt.SystemPromptTemplate; +import org.springframework.ai.content.Media; +import org.springframework.ai.converter.BeanOutputConverter; +import org.springframework.ai.converter.ListOutputConverter; +import org.springframework.ai.converter.MapOutputConverter; +import org.springframework.ai.model.tool.DefaultToolCallingManager; +import org.springframework.ai.model.tool.ToolCallingChatOptions; +import org.springframework.ai.model.tool.ToolCallingManager; +import org.springframework.ai.model.tool.ToolExecutionResult; +import org.springframework.ai.openaisdk.OpenAiSdkChatModel; +import org.springframework.ai.openaisdk.OpenAiSdkChatOptions; +import org.springframework.ai.openaisdk.OpenAiSdkChatOptions.AudioParameters; +import org.springframework.ai.openaisdk.OpenAiSdkChatOptions.AudioParameters.AudioResponseFormat; +import org.springframework.ai.openaisdk.OpenAiSdkChatOptions.AudioParameters.Voice; +import org.springframework.ai.openaisdk.OpenAiSdkChatOptions.StreamOptions; +import org.springframework.ai.openaisdk.OpenAiSdkTestConfiguration; +import org.springframework.ai.support.ToolCallbacks; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.function.FunctionToolCallback; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.util.MimeTypeUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Integration tests for {@link OpenAiSdkChatModel}. + * + * @author Julien Dubois + */ +@SpringBootTest(classes = OpenAiSdkTestConfiguration.class) +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") +public class OpenAiSdkChatModelIT { + + private static final Logger logger = LoggerFactory.getLogger(OpenAiSdkChatModelIT.class); + + // It would be better to use ChatModel.GPT_4O_AUDIO_PREVIEW.asString(); but it can't + // be used as a constant. + public static final String DEFAULT_CHAT_MODEL_AUDIO = "gpt-4o-audio-preview"; + + @Value("classpath:/prompts/system-message.st") + private Resource systemResource; + + @Autowired + private OpenAiSdkChatModel chatModel; + + @Test + void roleTest() { + UserMessage userMessage = new UserMessage( + "Tell me about 3 famous pirates from the Golden Age of Piracy and what they did."); + SystemPromptTemplate systemPromptTemplate = new SystemPromptTemplate(this.systemResource); + Message systemMessage = systemPromptTemplate.createMessage(Map.of("name", "Bob", "voice", "pirate")); + Prompt prompt = new Prompt(List.of(userMessage, systemMessage)); + ChatResponse response = this.chatModel.call(prompt); + assertThat(response.getResults()).hasSize(1); + assertThat(response.getResults().get(0).getOutput().getText()).contains("Blackbeard"); + // needs fine tuning... evaluateQuestionAndAnswer(request, response, false); + } + + @Test + void testMessageHistory() { + UserMessage userMessage = new UserMessage( + "Tell me about 3 famous pirates from the Golden Age of Piracy and why they did."); + SystemPromptTemplate systemPromptTemplate = new SystemPromptTemplate(this.systemResource); + Message systemMessage = systemPromptTemplate.createMessage(Map.of("name", "Bob", "voice", "pirate")); + Prompt prompt = new Prompt(List.of(userMessage, systemMessage)); + + ChatResponse response = this.chatModel.call(prompt); + assertThat(response.getResult().getOutput().getText()).containsAnyOf("Blackbeard", "Bartholomew"); + + var promptWithMessageHistory = new Prompt(List.of(new UserMessage("Dummy"), response.getResult().getOutput(), + new UserMessage("Repeat the last assistant message."))); + response = this.chatModel.call(promptWithMessageHistory); + + assertThat(response.getResult().getOutput().getText()).containsAnyOf("Blackbeard", "Bartholomew"); + } + + @Test + void streamCompletenessTest() throws InterruptedException { + UserMessage userMessage = new UserMessage( + "List ALL natural numbers in range [1, 100]. Make sure to not omit any. Print the full list here, one after another."); + Prompt prompt = new Prompt(List.of(userMessage)); + + StringBuilder answer = new StringBuilder(); + CountDownLatch latch = new CountDownLatch(1); + + Flux chatResponseFlux = this.chatModel.stream(prompt).doOnNext(chatResponse -> { + if (!chatResponse.getResults().isEmpty()) { + String responseContent = chatResponse.getResults().get(0).getOutput().getText(); + answer.append(responseContent); + } + }).doOnComplete(() -> { + logger.info(answer.toString()); + latch.countDown(); + }); + chatResponseFlux.subscribe(); + assertThat(latch.await(120, TimeUnit.SECONDS)).isTrue(); + IntStream.rangeClosed(1, 100).forEach(n -> assertThat(answer).contains(String.valueOf(n))); + } + + @Test + void streamCompletenessTestWithChatResponse() throws InterruptedException { + UserMessage userMessage = new UserMessage("Who is George Washington? - use first as 1st"); + Prompt prompt = new Prompt(List.of(userMessage)); + + StringBuilder answer = new StringBuilder(); + CountDownLatch latch = new CountDownLatch(1); + + ChatClient chatClient = ChatClient.builder(this.chatModel).build(); + + Flux chatResponseFlux = chatClient.prompt(prompt) + .stream() + .chatResponse() + .doOnNext(chatResponse -> { + if (!chatResponse.getResults().isEmpty()) { + String responseContent = chatResponse.getResults().get(0).getOutput().getText(); + answer.append(responseContent); + } + }) + .doOnComplete(() -> { + logger.info(answer.toString()); + latch.countDown(); + }); + chatResponseFlux.subscribe(); + assertThat(latch.await(120, TimeUnit.SECONDS)).isTrue(); + assertThat(answer).contains("1st "); + } + + @Test + void ensureChatResponseAsContentDoesNotSwallowBlankSpace() throws InterruptedException { + UserMessage userMessage = new UserMessage("Who is George Washington? - use first as 1st"); + Prompt prompt = new Prompt(List.of(userMessage)); + + StringBuilder answer = new StringBuilder(); + CountDownLatch latch = new CountDownLatch(1); + + ChatClient chatClient = ChatClient.builder(this.chatModel).build(); + + Flux chatResponseFlux = chatClient.prompt(prompt) + .stream() + .content() + .doOnNext(answer::append) + .doOnComplete(() -> { + logger.info(answer.toString()); + latch.countDown(); + }); + chatResponseFlux.subscribe(); + assertThat(latch.await(120, TimeUnit.SECONDS)).isTrue(); + assertThat(answer).contains("1st "); + } + + @Test + void streamRoleTest() { + UserMessage userMessage = new UserMessage( + "Tell me about 3 famous pirates from the Golden Age of Piracy and what they did."); + SystemPromptTemplate systemPromptTemplate = new SystemPromptTemplate(this.systemResource); + Message systemMessage = systemPromptTemplate.createMessage(Map.of("name", "Bob", "voice", "pirate")); + Prompt prompt = new Prompt(List.of(userMessage, systemMessage)); + Flux flux = this.chatModel.stream(prompt); + + List responses = flux.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("Blackbeard"); + + } + + @Test + void streamingWithTokenUsage() { + var promptOptions = OpenAiSdkChatOptions.builder() + .streamOptions(StreamOptions.builder().includeUsage(true).build()) + .reasoningEffort(ReasoningEffort.MINIMAL.toString()) + .seed(1) + .build(); + + var prompt = new Prompt("List two colors of the Polish flag. Be brief.", promptOptions); + var streamingTokenUsage = this.chatModel.stream(prompt).blockLast().getMetadata().getUsage(); + var referenceTokenUsage = this.chatModel.call(prompt).getMetadata().getUsage(); + + assertThat(streamingTokenUsage.getPromptTokens()).isGreaterThan(0); + assertThat(streamingTokenUsage.getCompletionTokens()).isGreaterThan(0); + assertThat(streamingTokenUsage.getTotalTokens()).isGreaterThan(0); + + assertThat(streamingTokenUsage.getPromptTokens()).isCloseTo(referenceTokenUsage.getPromptTokens(), + Percentage.withPercentage(25)); + assertThat(streamingTokenUsage.getCompletionTokens()).isCloseTo(referenceTokenUsage.getCompletionTokens(), + Percentage.withPercentage(25)); + assertThat(streamingTokenUsage.getTotalTokens()).isCloseTo(referenceTokenUsage.getTotalTokens(), + Percentage.withPercentage(25)); + + } + + @Test + void listOutputConverter() { + DefaultConversionService conversionService = new DefaultConversionService(); + ListOutputConverter outputConverter = new ListOutputConverter(conversionService); + + String format = outputConverter.getFormat(); + String template = """ + List five {subject} + {format} + """; + PromptTemplate promptTemplate = PromptTemplate.builder() + .template(template) + .variables(Map.of("subject", "ice cream flavors", "format", format)) + .build(); + Prompt prompt = new Prompt(promptTemplate.createMessage()); + Generation generation = this.chatModel.call(prompt).getResult(); + + List list = outputConverter.convert(generation.getOutput().getText()); + assertThat(list).hasSize(5); + + } + + @Test + void mapOutputConverter() { + MapOutputConverter outputConverter = new MapOutputConverter(); + + String format = outputConverter.getFormat(); + String template = """ + Provide me a List of {subject} + {format} + """; + PromptTemplate promptTemplate = PromptTemplate.builder() + .template(template) + .variables(Map.of("subject", "numbers from 1 to 9 under they key name 'numbers'", "format", format)) + .build(); + Prompt prompt = new Prompt(promptTemplate.createMessage()); + Generation generation = this.chatModel.call(prompt).getResult(); + + Map result = outputConverter.convert(generation.getOutput().getText()); + assertThat(result.get("numbers")).isEqualTo(Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9)); + + } + + @Test + void beanOutputConverter() { + + BeanOutputConverter outputConverter = new BeanOutputConverter<>(ActorsFilms.class); + + String format = outputConverter.getFormat(); + String template = """ + Generate the filmography for a random actor. + {format} + """; + PromptTemplate promptTemplate = PromptTemplate.builder() + .template(template) + .variables(Map.of("format", format)) + .build(); + Prompt prompt = new Prompt(promptTemplate.createMessage()); + Generation generation = this.chatModel.call(prompt).getResult(); + + ActorsFilms actorsFilms = outputConverter.convert(generation.getOutput().getText()); + } + + @Test + void beanOutputConverterRecords() { + + BeanOutputConverter outputConverter = new BeanOutputConverter<>(ActorsFilmsRecord.class); + + String format = outputConverter.getFormat(); + String template = """ + Generate the filmography of 5 movies for Tom Hanks. + {format} + """; + PromptTemplate promptTemplate = PromptTemplate.builder() + .template(template) + .variables(Map.of("format", format)) + .build(); + Prompt prompt = new Prompt(promptTemplate.createMessage()); + Generation generation = this.chatModel.call(prompt).getResult(); + + ActorsFilmsRecord actorsFilms = outputConverter.convert(generation.getOutput().getText()); + logger.info("" + actorsFilms); + assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks"); + assertThat(actorsFilms.movies()).hasSize(5); + } + + @Test + void beanStreamOutputConverterRecords() { + + BeanOutputConverter outputConverter = new BeanOutputConverter<>(ActorsFilmsRecord.class); + + String format = outputConverter.getFormat(); + String template = """ + Generate the filmography of 5 movies for Tom Hanks. + {format} + """; + PromptTemplate promptTemplate = PromptTemplate.builder() + .template(template) + .variables(Map.of("format", format)) + .build(); + Prompt prompt = new Prompt(promptTemplate.createMessage()); + + String generationTextFromStream = this.chatModel.stream(prompt) + .collectList() + .block() + .stream() + .map(ChatResponse::getResults) + .flatMap(List::stream) + .map(Generation::getOutput) + .map(AssistantMessage::getText) + .collect(Collectors.joining()); + + ActorsFilmsRecord actorsFilms = outputConverter.convert(generationTextFromStream); + logger.info("" + actorsFilms); + assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks"); + assertThat(actorsFilms.movies()).hasSize(5); + } + + @Test + void functionCallTest() { + + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? Answer in Celsius."); + + List messages = new ArrayList<>(List.of(userMessage)); + + var promptOptions = OpenAiSdkChatOptions.builder() + .toolCallbacks(List.of(FunctionToolCallback.builder("getCurrentWeather", new MockWeatherService()) + .description("Get the weather in location") + .inputType(MockWeatherService.Request.class) + .build())) + .build(); + + ChatResponse response = this.chatModel.call(new Prompt(messages, promptOptions)); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + } + + @Test + void streamFunctionCallTest() { + + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris in Celsius."); + + List messages = new ArrayList<>(List.of(userMessage)); + + var promptOptions = OpenAiSdkChatOptions.builder() + .toolCallbacks(List.of(FunctionToolCallback.builder("getCurrentWeather", new MockWeatherService()) + .description("Get the weather in location") + .inputType(MockWeatherService.Request.class) + .build())) + .build(); + + Flux response = this.chatModel.stream(new Prompt(messages, promptOptions)); + + String content = response.collectList() + .block() + .stream() + .map(ChatResponse::getResults) + .flatMap(List::stream) + .map(Generation::getOutput) + .map(AssistantMessage::getText) + .collect(Collectors.joining()); + + logger.info("Response: {}", content); + + assertThat(content).containsAnyOf("30.0", "30"); + assertThat(content).containsAnyOf("10.0", "10"); + assertThat(content).containsAnyOf("15.0", "15"); + } + + @Test + void functionCallUsageTest() { + + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? Answer in Celsius."); + + List messages = new ArrayList<>(List.of(userMessage)); + + var promptOptions = OpenAiSdkChatOptions.builder() + .toolCallbacks(List.of(FunctionToolCallback.builder("getCurrentWeather", new MockWeatherService()) + .description("Get the weather in location") + .inputType(MockWeatherService.Request.class) + .build())) + .build(); + + ChatResponse chatResponse = this.chatModel.call(new Prompt(messages, promptOptions)); + logger.info("Response: {}", chatResponse); + Usage usage = chatResponse.getMetadata().getUsage(); + + logger.info("Usage: {}", usage); + assertThat(usage).isNotNull(); + assertThat(usage).isNotInstanceOf(EmptyUsage.class); + assertThat(usage).isInstanceOf(DefaultUsage.class); + assertThat(usage.getPromptTokens()).isGreaterThan(500).isLessThan(800); + assertThat(usage.getCompletionTokens()).isGreaterThan(600).isLessThan(1200); + assertThat(usage.getTotalTokens()).isGreaterThan(1200).isLessThan(2000); + } + + @Test + void streamFunctionCallUsageTest() { + + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? Answer in Celsius."); + + List messages = new ArrayList<>(List.of(userMessage)); + + var promptOptions = OpenAiSdkChatOptions.builder() + .toolCallbacks(List.of(FunctionToolCallback.builder("getCurrentWeather", new MockWeatherService()) + .description("Get the weather in location") + .inputType(MockWeatherService.Request.class) + .build())) + .streamOptions(StreamOptions.builder().includeUsage(true).build()) + .reasoningEffort(ReasoningEffort.MINIMAL.toString()) + .build(); + + Flux response = this.chatModel.stream(new Prompt(messages, promptOptions)); + Usage usage = response.last().block().getMetadata().getUsage(); + + logger.info("Usage: {}", usage); + assertThat(usage).isNotNull(); + assertThat(usage).isNotInstanceOf(EmptyUsage.class); + assertThat(usage).isInstanceOf(DefaultUsage.class); + assertThat(usage.getPromptTokens()).isGreaterThan(500).isLessThan(800); + assertThat(usage.getCompletionTokens()).isGreaterThan(200).isLessThan(500); + assertThat(usage.getTotalTokens()).isGreaterThan(600).isLessThan(1300); + } + + @Test + void multiModalityEmbeddedImage() throws IOException { + + var imageData = new ClassPathResource("/test.png"); + + var userMessage = UserMessage.builder() + .text("Explain what do you see on this picture?") + .media(List.of(new Media(MimeTypeUtils.IMAGE_PNG, imageData))) + .build(); + + var response = this.chatModel.call(new Prompt(List.of(userMessage), OpenAiSdkChatOptions.builder().build())); + + logger.info(response.getResult().getOutput().getText()); + assertThat(response.getResult().getOutput().getText()).containsAnyOf("bananas", "apple", "bowl", "basket", + "fruit stand"); + } + + @Test + void multiModalityImageUrl() throws IOException { + + var userMessage = UserMessage.builder() + .text("Explain what do you see on this picture?") + .media(List.of(Media.builder() + .mimeType(MimeTypeUtils.IMAGE_PNG) + .data(URI.create("https://docs.spring.io/spring-ai/reference/_images/multimodal.test.png")) + .build())) + .build(); + + ChatResponse response = this.chatModel + .call(new Prompt(List.of(userMessage), OpenAiSdkChatOptions.builder().build())); + + logger.info(response.getResult().getOutput().getText()); + assertThat(response.getResult().getOutput().getText()).containsAnyOf("bananas", "apple", "bowl", "basket", + "fruit stand"); + } + + @Test + void streamingMultiModalityImageUrl() throws IOException { + + var userMessage = UserMessage.builder() + .text("Explain what do you see on this picture?") + .media(List.of(Media.builder() + .mimeType(MimeTypeUtils.IMAGE_PNG) + .data(URI.create("https://docs.spring.io/spring-ai/reference/_images/multimodal.test.png")) + .build())) + .build(); + + Flux response = this.chatModel + .stream(new Prompt(List.of(userMessage), OpenAiSdkChatOptions.builder().build())); + + String content = response.collectList() + .block() + .stream() + .map(ChatResponse::getResults) + .flatMap(List::stream) + .map(Generation::getOutput) + .map(AssistantMessage::getText) + .collect(Collectors.joining()); + logger.info("Response: {}", content); + assertThat(content).containsAnyOf("bananas", "apple", "bowl", "basket", "fruit stand"); + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { DEFAULT_CHAT_MODEL_AUDIO }) + void multiModalityOutputAudio(String modelName) throws IOException { + var userMessage = new UserMessage("Tell me joke about Spring Framework"); + + ChatResponse response = this.chatModel.call(new Prompt(List.of(userMessage), + OpenAiSdkChatOptions.builder() + .model(modelName) + .outputModalities(List.of("text", "audio")) + .outputAudio(new AudioParameters(Voice.ALLOY, AudioResponseFormat.WAV)) + .build())); + + logger.info(response.getResult().getOutput().getText()); + assertThat(response.getResult().getOutput().getText()).isNotEmpty(); + + byte[] audio = response.getResult().getOutput().getMedia().get(0).getDataAsByteArray(); + assertThat(audio).isNotEmpty(); + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { DEFAULT_CHAT_MODEL_AUDIO }) + void streamingMultiModalityOutputAudio(String modelName) { + var userMessage = new UserMessage("Tell me joke about Spring Framework"); + + assertThatThrownBy(() -> this.chatModel + .stream(new Prompt(List.of(userMessage), + OpenAiSdkChatOptions.builder() + .model(modelName) + .outputModalities(List.of("text", "audio")) + .outputAudio(new AudioParameters(Voice.ALLOY, AudioResponseFormat.WAV)) + .build())) + .collectList() + .block()).isInstanceOf(CompletionException.class) + .hasMessageContaining( + "audio.format' does not support 'wav' when stream=true. Supported values are: 'pcm16"); + } + + @Test + void validateCallResponseMetadata() { + String model = OpenAiSdkChatOptions.DEFAULT_CHAT_MODEL; + // @formatter:off + ChatResponse response = ChatClient.create(this.chatModel).prompt() + .options(OpenAiSdkChatOptions.builder().model(model).build()) + .user("Tell me about 3 famous pirates from the Golden Age of Piracy and what they did") + .call() + .chatResponse(); + // @formatter:on + + logger.info(response.toString()); + assertThat(response.getMetadata().getId()).isNotEmpty(); + assertThat(response.getMetadata().getModel()).containsIgnoringCase(model); + assertThat(response.getMetadata().getUsage().getPromptTokens()).isPositive(); + assertThat(response.getMetadata().getUsage().getCompletionTokens()).isPositive(); + assertThat(response.getMetadata().getUsage().getTotalTokens()).isPositive(); + } + + @Test + void validateStoreAndMetadata() { + OpenAiSdkChatOptions options = OpenAiSdkChatOptions.builder() + .store(true) + .metadata(Map.of("type", "dev")) + .build(); + + ChatResponse response = this.chatModel.call(new Prompt("Tell me a joke", options)); + + assertThat(response).isNotNull(); + } + + @Test + void chatMemory() { + ChatMemory memory = MessageWindowChatMemory.builder().build(); + String conversationId = UUID.randomUUID().toString(); + + UserMessage userMessage1 = new UserMessage("My name is James Bond"); + memory.add(conversationId, userMessage1); + ChatResponse response1 = this.chatModel.call(new Prompt(memory.get(conversationId))); + + assertThat(response1).isNotNull(); + memory.add(conversationId, response1.getResult().getOutput()); + + UserMessage userMessage2 = new UserMessage("What is my name?"); + memory.add(conversationId, userMessage2); + ChatResponse response2 = this.chatModel.call(new Prompt(memory.get(conversationId))); + + assertThat(response2).isNotNull(); + memory.add(conversationId, response2.getResult().getOutput()); + + assertThat(response2.getResults()).hasSize(1); + assertThat(response2.getResult().getOutput().getText()).contains("James Bond"); + } + + @Test + void chatMemoryWithTools() { + ToolCallingManager toolCallingManager = DefaultToolCallingManager.builder().build(); + ChatMemory chatMemory = MessageWindowChatMemory.builder().build(); + String conversationId = UUID.randomUUID().toString(); + + ChatOptions chatOptions = ToolCallingChatOptions.builder() + .toolCallbacks(ToolCallbacks.from(new MathTools())) + .internalToolExecutionEnabled(false) + .build(); + Prompt prompt = new Prompt( + List.of(new SystemMessage("You are a helpful assistant."), new UserMessage("What is 6 * 8?")), + chatOptions); + chatMemory.add(conversationId, prompt.getInstructions()); + + Prompt promptWithMemory = new Prompt(chatMemory.get(conversationId), chatOptions); + ChatResponse chatResponse = this.chatModel.call(promptWithMemory); + chatMemory.add(conversationId, chatResponse.getResult().getOutput()); + + while (chatResponse.hasToolCalls()) { + ToolExecutionResult toolExecutionResult = toolCallingManager.executeToolCalls(promptWithMemory, + chatResponse); + chatMemory.add(conversationId, toolExecutionResult.conversationHistory() + .get(toolExecutionResult.conversationHistory().size() - 1)); + promptWithMemory = new Prompt(chatMemory.get(conversationId), chatOptions); + chatResponse = this.chatModel.call(promptWithMemory); + chatMemory.add(conversationId, chatResponse.getResult().getOutput()); + } + + assertThat(chatResponse).isNotNull(); + assertThat(chatResponse.getResult().getOutput().getText()).contains("48"); + + UserMessage newUserMessage = new UserMessage("What did I ask you earlier?"); + chatMemory.add(conversationId, newUserMessage); + + ChatResponse newResponse = this.chatModel.call(new Prompt(chatMemory.get(conversationId))); + + assertThat(newResponse).isNotNull(); + assertThat(newResponse.getResult().getOutput().getText()).contains("6").contains("8"); + } + + record ActorsFilmsRecord(String actor, List movies) { + + } + + static class MathTools { + + @Tool(description = "Multiply the two numbers") + double multiply(double a, double b) { + return a * b; + } + + } + +} diff --git a/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/chat/OpenAiSdkChatModelObservationIT.java b/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/chat/OpenAiSdkChatModelObservationIT.java new file mode 100644 index 00000000000..09078ac8c89 --- /dev/null +++ b/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/chat/OpenAiSdkChatModelObservationIT.java @@ -0,0 +1,154 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.openaisdk.chat; + +import java.util.List; +import java.util.stream.Collectors; + +import io.micrometer.observation.tck.TestObservationRegistry; +import io.micrometer.observation.tck.TestObservationRegistryAssert; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import reactor.core.publisher.Flux; + +import org.springframework.ai.chat.metadata.ChatResponseMetadata; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.observation.ChatModelObservationDocumentation.HighCardinalityKeyNames; +import org.springframework.ai.chat.observation.ChatModelObservationDocumentation.LowCardinalityKeyNames; +import org.springframework.ai.chat.observation.DefaultChatModelObservationConvention; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.observation.conventions.AiOperationType; +import org.springframework.ai.observation.conventions.AiProvider; +import org.springframework.ai.openaisdk.OpenAiSdkChatModel; +import org.springframework.ai.openaisdk.OpenAiSdkChatOptions; +import org.springframework.ai.openaisdk.OpenAiSdkChatOptions.StreamOptions; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for observation instrumentation in {@link OpenAiSdkChatModel}. + * + * @author Julien Dubois + */ +@SpringBootTest +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") +public class OpenAiSdkChatModelObservationIT { + + @Autowired + TestObservationRegistry observationRegistry; + + @Autowired + private OpenAiSdkChatModel chatModel; + + @BeforeEach + void setUp() { + this.observationRegistry.clear(); + } + + @Test + void observationForChatOperation() throws InterruptedException { + + var options = OpenAiSdkChatOptions.builder().model(OpenAiSdkChatOptions.DEFAULT_CHAT_MODEL).build(); + + Prompt prompt = new Prompt("Why does a raven look like a desk?", options); + + ChatResponse chatResponse = this.chatModel.call(prompt); + assertThat(chatResponse.getResult().getOutput().getText()).isNotEmpty(); + + ChatResponseMetadata responseMetadata = chatResponse.getMetadata(); + assertThat(responseMetadata).isNotNull(); + + validate(responseMetadata); + } + + @Test + void observationForStreamingChatOperation() throws InterruptedException { + var options = OpenAiSdkChatOptions.builder() + .model(OpenAiSdkChatOptions.DEFAULT_CHAT_MODEL) + .streamOptions(StreamOptions.builder().includeUsage(true).build()) + .build(); + + Prompt prompt = new Prompt("Why does a raven look like a desk?", options); + + Flux chatResponseFlux = this.chatModel.stream(prompt); + + List responses = chatResponseFlux.collectList().block(); + assertThat(responses).isNotEmpty(); + assertThat(responses).hasSizeGreaterThan(10); + + String aggregatedResponse = responses.subList(0, responses.size() - 1) + .stream() + .map(r -> r.getResult() != null ? r.getResult().getOutput().getText() : "") + .collect(Collectors.joining()); + assertThat(aggregatedResponse).isNotEmpty(); + + ChatResponse lastChatResponse = responses.get(responses.size() - 1); + + ChatResponseMetadata responseMetadata = lastChatResponse.getMetadata(); + assertThat(responseMetadata).isNotNull(); + + validate(responseMetadata); + } + + private void validate(ChatResponseMetadata responseMetadata) throws InterruptedException { + Thread.sleep(100); // Wait for observation to be recorded + + TestObservationRegistryAssert.assertThat(this.observationRegistry) + .doesNotHaveAnyRemainingCurrentObservation() + .hasObservationWithNameEqualTo(DefaultChatModelObservationConvention.DEFAULT_NAME) + .that() + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.AI_OPERATION_TYPE.asString(), + AiOperationType.CHAT.value()) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.AI_PROVIDER.asString(), AiProvider.OPENAI_SDK.value()) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.REQUEST_MODEL.asString(), + OpenAiSdkChatOptions.DEFAULT_CHAT_MODEL) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.RESPONSE_MODEL.asString(), responseMetadata.getModel()) + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.RESPONSE_ID.asString(), responseMetadata.getId()) + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.RESPONSE_FINISH_REASONS.asString(), "[\"STOP\"]") + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.USAGE_INPUT_TOKENS.asString(), + String.valueOf(responseMetadata.getUsage().getPromptTokens())) + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.USAGE_OUTPUT_TOKENS.asString(), + String.valueOf(responseMetadata.getUsage().getCompletionTokens())) + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.USAGE_TOTAL_TOKENS.asString(), + String.valueOf(responseMetadata.getUsage().getTotalTokens())) + .hasBeenStarted() + .hasBeenStopped(); + } + + @SpringBootConfiguration + static class Config { + + @Bean + public TestObservationRegistry observationRegistry() { + return TestObservationRegistry.create(); + } + + @Bean + public OpenAiSdkChatModel openAiChatModel(TestObservationRegistry observationRegistry) { + return new OpenAiSdkChatModel( + OpenAiSdkChatOptions.builder().model(OpenAiSdkChatOptions.DEFAULT_CHAT_MODEL).build(), + observationRegistry); + } + + } + +} diff --git a/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/chat/OpenAiSdkChatModelResponseFormatIT.java b/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/chat/OpenAiSdkChatModelResponseFormatIT.java new file mode 100644 index 00000000000..86ac56058a2 --- /dev/null +++ b/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/chat/OpenAiSdkChatModelResponseFormatIT.java @@ -0,0 +1,268 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.openaisdk.chat; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.core.JacksonException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.converter.BeanOutputConverter; +import org.springframework.ai.openaisdk.OpenAiSdkChatModel; +import org.springframework.ai.openaisdk.OpenAiSdkChatOptions; +import org.springframework.ai.openaisdk.OpenAiSdkTestConfiguration; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for the response format in {@link OpenAiSdkChatModel}. + * + * @author Julien Dubois + */ +@SpringBootTest(classes = OpenAiSdkTestConfiguration.class) +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") +public class OpenAiSdkChatModelResponseFormatIT { + + private static final ObjectMapper MAPPER = new ObjectMapper() + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + @Autowired + private OpenAiSdkChatModel chatModel; + + public static boolean isValidJson(String json) { + try { + MAPPER.readTree(json); + } + catch (JacksonException e) { + return false; + } + return true; + } + + @Test + void jsonObject() { + + Prompt prompt = new Prompt("List 8 planets. Use JSON response", + OpenAiSdkChatOptions.builder() + .responseFormat(OpenAiSdkChatModel.ResponseFormat.builder() + .type(OpenAiSdkChatModel.ResponseFormat.Type.JSON_OBJECT) + .build()) + .build()); + + ChatResponse response = this.chatModel.call(prompt); + + assertThat(response).isNotNull(); + + String content = response.getResult().getOutput().getText(); + + logger.info("Response content: {}", content); + + assertThat(isValidJson(content)).isTrue(); + } + + @Test + void jsonSchema() { + + var jsonSchema = """ + { + "type": "object", + "properties": { + "steps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "explanation": { "type": "string" }, + "output": { "type": "string" } + }, + "required": ["explanation", "output"], + "additionalProperties": false + } + }, + "final_answer": { "type": "string" } + }, + "required": ["steps", "final_answer"], + "additionalProperties": false + } + """; + + Prompt prompt = new Prompt("how can I solve 8x + 7 = -23", + OpenAiSdkChatOptions.builder() + .model(OpenAiSdkChatOptions.DEFAULT_CHAT_MODEL) + .responseFormat(OpenAiSdkChatModel.ResponseFormat.builder() + .type(OpenAiSdkChatModel.ResponseFormat.Type.JSON_SCHEMA) + .jsonSchema(jsonSchema) + .build()) + .build()); + + ChatResponse response = this.chatModel.call(prompt); + + assertThat(response).isNotNull(); + + String content = response.getResult().getOutput().getText(); + + logger.info("Response content: {}", content); + + assertThat(isValidJson(content)).isTrue(); + } + + @Test + void jsonSchemaThroughIndividualSetters() throws JsonProcessingException { + + var jsonSchema = """ + { + "type": "object", + "properties": { + "steps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "explanation": { "type": "string" }, + "output": { "type": "string" } + }, + "required": ["explanation", "output"], + "additionalProperties": false + } + }, + "final_answer": { "type": "string" } + }, + "required": ["steps", "final_answer"], + "additionalProperties": false + } + """; + + Prompt prompt = new Prompt("how can I solve 8x + 7 = -23", + OpenAiSdkChatOptions.builder() + .model(OpenAiSdkChatOptions.DEFAULT_CHAT_MODEL) + .responseFormat(OpenAiSdkChatModel.ResponseFormat.builder().jsonSchema(jsonSchema).build()) + .build()); + + ChatResponse response = this.chatModel.call(prompt); + + assertThat(response).isNotNull(); + + String content = response.getResult().getOutput().getText(); + + logger.info("Response content: {}", content); + + assertThat(isValidJson(content)).isTrue(); + } + + @Test + void jsonSchemaBeanConverter() { + + @JsonPropertyOrder({ "steps", "final_answer" }) + record MathReasoning(@JsonProperty(required = true, value = "steps") Steps steps, + @JsonProperty(required = true, value = "final_answer") String finalAnswer) { + + record Steps(@JsonProperty(required = true, value = "items") Items[] items) { + + @JsonPropertyOrder({ "output", "explanation" }) + record Items(@JsonProperty(required = true, value = "explanation") String explanation, + @JsonProperty(required = true, value = "output") String output) { + + } + + } + + } + + var outputConverter = new BeanOutputConverter<>(MathReasoning.class); + // @formatter:off + // CHECKSTYLE:OFF + var expectedJsonSchema = """ + { + "$schema" : "https://json-schema.org/draft/2020-12/schema", + "type" : "object", + "properties" : { + "steps" : { + "type" : "object", + "properties" : { + "items" : { + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "output" : { + "type" : "string" + }, + "explanation" : { + "type" : "string" + } + }, + "required" : [ "output", "explanation" ], + "additionalProperties" : false + } + } + }, + "required" : [ "items" ], + "additionalProperties" : false + }, + "final_answer" : { + "type" : "string" + } + }, + "required" : [ "steps", "final_answer" ], + "additionalProperties" : false + }"""; + // @formatter:on + // CHECKSTYLE:ON + var jsonSchema1 = outputConverter.getJsonSchema(); + + assertThat(jsonSchema1).isNotNull(); + assertThat(jsonSchema1).isEqualTo(expectedJsonSchema); + + Prompt prompt = new Prompt("how can I solve 8x + 7 = -23", + OpenAiSdkChatOptions.builder() + .model(OpenAiSdkChatOptions.DEFAULT_CHAT_MODEL) + .responseFormat(OpenAiSdkChatModel.ResponseFormat.builder().jsonSchema(jsonSchema1).build()) + .build()); + + ChatResponse response = this.chatModel.call(prompt); + + assertThat(response).isNotNull(); + + String content = response.getResult().getOutput().getText(); + + logger.info("Response content: {}", content); + + assertThat(isValidJson(content)).isTrue(); + + // Check if the order is correct as specified in the schema. Steps should come + // first before final answer. + // assertThat(content.startsWith("{\"steps\":{\"items\":[")).isTrue(); + + MathReasoning mathReasoning = outputConverter.convert(content); + + assertThat(mathReasoning).isNotNull(); + logger.info(mathReasoning.toString()); + } + +} diff --git a/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/chat/OpenAiSdkChatOptionsTests.java b/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/chat/OpenAiSdkChatOptionsTests.java new file mode 100644 index 00000000000..f1ef3494681 --- /dev/null +++ b/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/chat/OpenAiSdkChatOptionsTests.java @@ -0,0 +1,679 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.openaisdk.chat; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import org.springframework.ai.openaisdk.OpenAiSdkChatOptions; +import org.springframework.ai.openaisdk.OpenAiSdkChatOptions.StreamOptions; +import org.springframework.ai.tool.ToolCallback; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests for {@link OpenAiSdkChatOptions}. + * + * @author Julien Dubois + */ +public class OpenAiSdkChatOptionsTests { + + @Test + void testBuilderWithAllFields() { + Map logitBias = new HashMap<>(); + logitBias.put("token1", 1); + logitBias.put("token2", -1); + + List stop = List.of("stop1", "stop2"); + Map metadata = Map.of("key1", "value1"); + Map toolContext = Map.of("keyA", "valueA"); + Map customHeaders = Map.of("header1", "value1"); + + OpenAiSdkChatOptions options = OpenAiSdkChatOptions.builder() + .model("test-model") + .deploymentName("test-deployment") + .frequencyPenalty(0.5) + .logitBias(logitBias) + .logprobs(true) + .topLogprobs(5) + .maxTokens(100) + .maxCompletionTokens(50) + .N(2) + .presencePenalty(0.8) + .streamOptions(StreamOptions.builder().includeUsage(true).build()) + .seed(12345) + .stop(stop) + .temperature(0.7) + .topP(0.9) + .user("test-user") + .parallelToolCalls(true) + .store(false) + .metadata(metadata) + .reasoningEffort("medium") + .verbosity("low") + .serviceTier("auto") + .internalToolExecutionEnabled(false) + .customHeaders(customHeaders) + .toolContext(toolContext) + .build(); + + assertThat(options.getModel()).isEqualTo("test-model"); + assertThat(options.getDeploymentName()).isEqualTo("test-deployment"); + assertThat(options.getFrequencyPenalty()).isEqualTo(0.5); + assertThat(options.getLogitBias()).isEqualTo(logitBias); + assertThat(options.getLogprobs()).isTrue(); + assertThat(options.getTopLogprobs()).isEqualTo(5); + assertThat(options.getMaxTokens()).isNull(); + assertThat(options.getMaxCompletionTokens()).isEqualTo(50); + assertThat(options.getN()).isEqualTo(2); + assertThat(options.getPresencePenalty()).isEqualTo(0.8); + assertThat(options.getStreamOptions().includeUsage()).isTrue(); + assertThat(options.getSeed()).isEqualTo(12345); + assertThat(options.getStop()).isEqualTo(stop); + assertThat(options.getStopSequences()).isEqualTo(stop); + assertThat(options.getTemperature()).isEqualTo(0.7); + assertThat(options.getTopP()).isEqualTo(0.9); + assertThat(options.getUser()).isEqualTo("test-user"); + assertThat(options.getParallelToolCalls()).isTrue(); + assertThat(options.getStore()).isFalse(); + assertThat(options.getMetadata()).isEqualTo(metadata); + assertThat(options.getReasoningEffort()).isEqualTo("medium"); + assertThat(options.getVerbosity()).isEqualTo("low"); + assertThat(options.getServiceTier()).isEqualTo("auto"); + assertThat(options.getInternalToolExecutionEnabled()).isFalse(); + assertThat(options.getCustomHeaders()).isEqualTo(customHeaders); + assertThat(options.getToolContext()).isEqualTo(toolContext); + } + + @Test + void testCopy() { + Map logitBias = new HashMap<>(); + logitBias.put("token1", 1); + + List stop = List.of("stop1"); + Map metadata = Map.of("key1", "value1"); + + OpenAiSdkChatOptions originalOptions = OpenAiSdkChatOptions.builder() + .model("test-model") + .deploymentName("test-deployment") + .frequencyPenalty(0.5) + .logitBias(logitBias) + .logprobs(true) + .topLogprobs(5) + .maxCompletionTokens(50) + .N(2) + .presencePenalty(0.8) + .streamOptions(StreamOptions.builder().includeUsage(false).build()) + .seed(12345) + .stop(stop) + .temperature(0.7) + .topP(0.9) + .user("test-user") + .parallelToolCalls(false) + .store(true) + .metadata(metadata) + .reasoningEffort("low") + .verbosity("high") + .serviceTier("default") + .internalToolExecutionEnabled(true) + .customHeaders(Map.of("header1", "value1")) + .build(); + + OpenAiSdkChatOptions copiedOptions = originalOptions.copy(); + + assertThat(copiedOptions).isNotSameAs(originalOptions).isEqualTo(originalOptions); + // Verify collections are copied + assertThat(copiedOptions.getStop()).isNotSameAs(originalOptions.getStop()); + assertThat(copiedOptions.getCustomHeaders()).isNotSameAs(originalOptions.getCustomHeaders()); + assertThat(copiedOptions.getToolCallbacks()).isNotSameAs(originalOptions.getToolCallbacks()); + assertThat(copiedOptions.getToolNames()).isNotSameAs(originalOptions.getToolNames()); + assertThat(copiedOptions.getToolContext()).isNotSameAs(originalOptions.getToolContext()); + } + + @Test + void testSetters() { + Map logitBias = new HashMap<>(); + logitBias.put("token1", 1); + + List stop = List.of("stop1", "stop2"); + Map metadata = Map.of("key2", "value2"); + + OpenAiSdkChatOptions options = new OpenAiSdkChatOptions(); + options.setModel("test-model"); + options.setDeploymentName("test-deployment"); + options.setFrequencyPenalty(0.5); + options.setLogitBias(logitBias); + options.setLogprobs(true); + options.setTopLogprobs(5); + options.setMaxTokens(100); + options.setMaxCompletionTokens(50); + options.setN(2); + options.setPresencePenalty(0.8); + options.setStreamOptions(StreamOptions.builder().includeUsage(true).build()); + options.setSeed(12345); + options.setStop(stop); + options.setTemperature(0.7); + options.setTopP(0.9); + options.setUser("test-user"); + options.setParallelToolCalls(true); + options.setStore(false); + options.setMetadata(metadata); + options.setReasoningEffort("high"); + options.setVerbosity("medium"); + options.setServiceTier("auto"); + options.setInternalToolExecutionEnabled(false); + options.setCustomHeaders(Map.of("header2", "value2")); + + assertThat(options.getModel()).isEqualTo("test-model"); + assertThat(options.getDeploymentName()).isEqualTo("test-deployment"); + assertThat(options.getFrequencyPenalty()).isEqualTo(0.5); + assertThat(options.getLogitBias()).isEqualTo(logitBias); + assertThat(options.getLogprobs()).isTrue(); + assertThat(options.getTopLogprobs()).isEqualTo(5); + assertThat(options.getMaxTokens()).isEqualTo(100); + assertThat(options.getMaxCompletionTokens()).isEqualTo(50); + assertThat(options.getN()).isEqualTo(2); + assertThat(options.getPresencePenalty()).isEqualTo(0.8); + assertThat(options.getStreamOptions().includeUsage()).isTrue(); + assertThat(options.getSeed()).isEqualTo(12345); + assertThat(options.getStop()).isEqualTo(stop); + assertThat(options.getTemperature()).isEqualTo(0.7); + assertThat(options.getTopP()).isEqualTo(0.9); + assertThat(options.getUser()).isEqualTo("test-user"); + assertThat(options.getParallelToolCalls()).isTrue(); + assertThat(options.getStore()).isFalse(); + assertThat(options.getMetadata()).isEqualTo(metadata); + assertThat(options.getReasoningEffort()).isEqualTo("high"); + assertThat(options.getVerbosity()).isEqualTo("medium"); + assertThat(options.getServiceTier()).isEqualTo("auto"); + assertThat(options.getInternalToolExecutionEnabled()).isFalse(); + assertThat(options.getCustomHeaders()).isEqualTo(Map.of("header2", "value2")); + } + + @Test + void testDefaultValues() { + OpenAiSdkChatOptions options = new OpenAiSdkChatOptions(); + + assertThat(options.getModel()).isNull(); + assertThat(options.getDeploymentName()).isNull(); + assertThat(options.getFrequencyPenalty()).isNull(); + assertThat(options.getLogitBias()).isNull(); + assertThat(options.getLogprobs()).isNull(); + assertThat(options.getTopLogprobs()).isNull(); + assertThat(options.getMaxTokens()).isNull(); + assertThat(options.getMaxCompletionTokens()).isNull(); + assertThat(options.getN()).isNull(); + assertThat(options.getOutputAudio()).isNull(); + assertThat(options.getPresencePenalty()).isNull(); + assertThat(options.getResponseFormat()).isNull(); + assertThat(options.getStreamOptions()).isNull(); + assertThat(options.getStreamOptions()).isNull(); + assertThat(options.getSeed()).isNull(); + assertThat(options.getStop()).isNull(); + assertThat(options.getStopSequences()).isNull(); + assertThat(options.getTemperature()).isNull(); + assertThat(options.getTopP()).isNull(); + assertThat(options.getTopK()).isNull(); + assertThat(options.getToolChoice()).isNull(); + assertThat(options.getUser()).isNull(); + assertThat(options.getParallelToolCalls()).isNull(); + assertThat(options.getStore()).isNull(); + assertThat(options.getMetadata()).isNull(); + assertThat(options.getReasoningEffort()).isNull(); + assertThat(options.getVerbosity()).isNull(); + assertThat(options.getServiceTier()).isNull(); + assertThat(options.getToolCallbacks()).isNotNull().isEmpty(); + assertThat(options.getToolNames()).isNotNull().isEmpty(); + assertThat(options.getInternalToolExecutionEnabled()).isNull(); + assertThat(options.getCustomHeaders()).isNotNull().isEmpty(); + assertThat(options.getToolContext()).isNotNull().isEmpty(); + } + + @Test + void testEqualsAndHashCode() { + OpenAiSdkChatOptions options1 = OpenAiSdkChatOptions.builder() + .model("test-model") + .temperature(0.7) + .maxTokens(100) + .build(); + + OpenAiSdkChatOptions options2 = OpenAiSdkChatOptions.builder() + .model("test-model") + .temperature(0.7) + .maxTokens(100) + .build(); + + OpenAiSdkChatOptions options3 = OpenAiSdkChatOptions.builder() + .model("different-model") + .temperature(0.7) + .maxTokens(100) + .build(); + + // Test equals + assertThat(options1).isEqualTo(options2); + assertThat(options1).isNotEqualTo(options3); + assertThat(options1).isNotEqualTo(null); + + // Test hashCode + assertThat(options1.hashCode()).isEqualTo(options2.hashCode()); + } + + @Test + void testBuilderWithNullValues() { + OpenAiSdkChatOptions options = OpenAiSdkChatOptions.builder() + .temperature(null) + .logitBias(null) + .stop(null) + .metadata(null) + .customHeaders(null) + .build(); + + assertThat(options.getModel()).isNull(); + assertThat(options.getTemperature()).isNull(); + assertThat(options.getLogitBias()).isNull(); + assertThat(options.getStop()).isNull(); + assertThat(options.getMetadata()).isNull(); + assertThat(options.getCustomHeaders()).isNull(); + } + + @Test + void testBuilderChaining() { + OpenAiSdkChatOptions.Builder builder = OpenAiSdkChatOptions.builder(); + + OpenAiSdkChatOptions.Builder result = builder.model("test-model").temperature(0.7).maxTokens(100); + + assertThat(result).isSameAs(builder); + + OpenAiSdkChatOptions options = result.build(); + assertThat(options.getModel()).isEqualTo("test-model"); + assertThat(options.getTemperature()).isEqualTo(0.7); + assertThat(options.getMaxTokens()).isEqualTo(100); + } + + @Test + void testNullAndEmptyCollections() { + OpenAiSdkChatOptions options = new OpenAiSdkChatOptions(); + + // Test setting null collections + options.setLogitBias(null); + options.setStop(null); + options.setMetadata(null); + options.setCustomHeaders(null); + + assertThat(options.getLogitBias()).isNull(); + assertThat(options.getStop()).isNull(); + assertThat(options.getMetadata()).isNull(); + assertThat(options.getCustomHeaders()).isNull(); + + // Test setting empty collections + options.setLogitBias(new HashMap<>()); + options.setStop(new ArrayList<>()); + options.setMetadata(new HashMap<>()); + options.setCustomHeaders(new HashMap<>()); + + assertThat(options.getLogitBias()).isEmpty(); + assertThat(options.getStop()).isEmpty(); + assertThat(options.getMetadata()).isEmpty(); + assertThat(options.getCustomHeaders()).isEmpty(); + } + + @Test + void testStopSequencesAlias() { + OpenAiSdkChatOptions options = new OpenAiSdkChatOptions(); + List stopSequences = List.of("stop1", "stop2"); + + // Setting stopSequences should also set stop + options.setStopSequences(stopSequences); + assertThat(options.getStopSequences()).isEqualTo(stopSequences); + assertThat(options.getStop()).isEqualTo(stopSequences); + + // Setting stop should also update stopSequences + List newStop = List.of("stop3", "stop4"); + options.setStop(newStop); + assertThat(options.getStop()).isEqualTo(newStop); + assertThat(options.getStopSequences()).isEqualTo(newStop); + } + + @Test + void testCopyChangeIndependence() { + OpenAiSdkChatOptions original = OpenAiSdkChatOptions.builder().model("original-model").temperature(0.5).build(); + + OpenAiSdkChatOptions copied = original.copy(); + + // Modify original + original.setModel("modified-model"); + original.setTemperature(0.9); + + // Verify copy is unchanged + assertThat(copied.getModel()).isEqualTo("original-model"); + assertThat(copied.getTemperature()).isEqualTo(0.5); + } + + @Test + void testMaxTokensIsDeprectaed() { + // Test that setting maxCompletionTokens takes precedence over maxTokens in + // builder + OpenAiSdkChatOptions options = OpenAiSdkChatOptions.builder().maxCompletionTokens(100).maxTokens(50).build(); + + assertThat(options.getMaxTokens()).isNull(); + assertThat(options.getMaxCompletionTokens()).isEqualTo(100); + } + + @Test + void testMaxCompletionTokensMutualExclusivityValidation() { + // Test that setting maxCompletionTokens clears maxTokens in builder + OpenAiSdkChatOptions options = OpenAiSdkChatOptions.builder().maxTokens(50).maxCompletionTokens(100).build(); + + assertThat(options.getMaxTokens()).isNull(); + assertThat(options.getMaxCompletionTokens()).isEqualTo(100); + } + + @Test + void testMaxTokensWithNullDoesNotClearMaxCompletionTokens() { + // Test that setting maxTokens to null doesn't trigger validation + OpenAiSdkChatOptions options = OpenAiSdkChatOptions.builder().maxCompletionTokens(100).maxTokens(null).build(); + + assertThat(options.getMaxTokens()).isNull(); + assertThat(options.getMaxCompletionTokens()).isEqualTo(100); + } + + @Test + void testMaxCompletionTokensWithNullDoesNotClearMaxTokens() { + // Test that setting maxCompletionTokens to null doesn't trigger validation + OpenAiSdkChatOptions options = OpenAiSdkChatOptions.builder().maxTokens(50).maxCompletionTokens(null).build(); + + assertThat(options.getMaxTokens()).isEqualTo(50); + assertThat(options.getMaxCompletionTokens()).isNull(); + } + + @Test + void testBuilderCanSetOnlyMaxTokens() { + OpenAiSdkChatOptions options = OpenAiSdkChatOptions.builder().maxTokens(100).build(); + + assertThat(options.getMaxTokens()).isEqualTo(100); + assertThat(options.getMaxCompletionTokens()).isNull(); + } + + @Test + void testBuilderCanSetOnlyMaxCompletionTokens() { + OpenAiSdkChatOptions options = OpenAiSdkChatOptions.builder().maxCompletionTokens(150).build(); + + assertThat(options.getMaxTokens()).isNull(); + assertThat(options.getMaxCompletionTokens()).isEqualTo(150); + } + + @Test + void testSettersMutualExclusivityNotEnforced() { + // Test that direct setters do NOT enforce mutual exclusivity (only builder does) + OpenAiSdkChatOptions options = new OpenAiSdkChatOptions(); + options.setMaxTokens(50); + options.setMaxCompletionTokens(100); + + // Both should be set when using setters directly + assertThat(options.getMaxTokens()).isEqualTo(50); + assertThat(options.getMaxCompletionTokens()).isEqualTo(100); + } + + @Test + void testToolCallbacksAndNames() { + ToolCallback callback1 = new ToolCallback() { + @Override + public org.springframework.ai.tool.definition.ToolDefinition getToolDefinition() { + return org.springframework.ai.tool.definition.DefaultToolDefinition.builder() + .name("tool1") + .description("desc1") + .inputSchema("{}") + .build(); + } + + @Override + public String call(String toolInput) { + return "result1"; + } + }; + + ToolCallback callback2 = new ToolCallback() { + @Override + public org.springframework.ai.tool.definition.ToolDefinition getToolDefinition() { + return org.springframework.ai.tool.definition.DefaultToolDefinition.builder() + .name("tool2") + .description("desc2") + .inputSchema("{}") + .build(); + } + + @Override + public String call(String toolInput) { + return "result2"; + } + }; + + OpenAiSdkChatOptions options = OpenAiSdkChatOptions.builder() + .toolCallbacks(callback1, callback2) + .toolNames("tool1", "tool2") + .build(); + + assertThat(options.getToolCallbacks()).hasSize(2).containsExactly(callback1, callback2); + assertThat(options.getToolNames()).hasSize(2).contains("tool1", "tool2"); + } + + @Test + void testToolCallbacksList() { + ToolCallback callback = new ToolCallback() { + @Override + public org.springframework.ai.tool.definition.ToolDefinition getToolDefinition() { + return org.springframework.ai.tool.definition.DefaultToolDefinition.builder() + .name("tool") + .description("desc") + .inputSchema("{}") + .build(); + } + + @Override + public String call(String toolInput) { + return "result"; + } + }; + List callbacks = List.of(callback); + + OpenAiSdkChatOptions options = OpenAiSdkChatOptions.builder().toolCallbacks(callbacks).build(); + + assertThat(options.getToolCallbacks()).hasSize(1).containsExactly(callback); + } + + @Test + void testToolNamesSet() { + Set toolNames = new HashSet<>(Arrays.asList("tool1", "tool2", "tool3")); + + OpenAiSdkChatOptions options = OpenAiSdkChatOptions.builder().toolNames(toolNames).build(); + + assertThat(options.getToolNames()).hasSize(3).containsExactlyInAnyOrder("tool1", "tool2", "tool3"); + } + + @Test + @SuppressWarnings("DataFlowIssue") + void testSetToolCallbacksValidation() { + OpenAiSdkChatOptions options = new OpenAiSdkChatOptions(); + + // Test null validation + assertThatThrownBy(() -> options.setToolCallbacks(null)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("toolCallbacks cannot be null"); + + // Test null elements validation + List callbacksWithNull = new ArrayList<>(); + callbacksWithNull.add(null); + assertThatThrownBy(() -> options.setToolCallbacks(callbacksWithNull)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("toolCallbacks cannot contain null elements"); + } + + @Test + @SuppressWarnings("DataFlowIssue") + void testSetToolNamesValidation() { + OpenAiSdkChatOptions options = new OpenAiSdkChatOptions(); + + // Test null validation + assertThatThrownBy(() -> options.setToolNames(null)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("toolNames cannot be null"); + + // Test null elements validation + Set toolNamesWithNull = new HashSet<>(); + toolNamesWithNull.add(null); + assertThatThrownBy(() -> options.setToolNames(toolNamesWithNull)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("toolNames cannot contain null elements"); + + // Test empty string validation + Set toolNamesWithEmpty = new HashSet<>(); + toolNamesWithEmpty.add(""); + assertThatThrownBy(() -> options.setToolNames(toolNamesWithEmpty)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("toolNames cannot contain empty elements"); + + // Test whitespace string validation + Set toolNamesWithWhitespace = new HashSet<>(); + toolNamesWithWhitespace.add(" "); + assertThatThrownBy(() -> options.setToolNames(toolNamesWithWhitespace)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("toolNames cannot contain empty elements"); + } + + @Test + void testBuilderMerge() { + OpenAiSdkChatOptions base = OpenAiSdkChatOptions.builder() + .model("base-model") + .temperature(0.5) + .maxTokens(100) + .build(); + + OpenAiSdkChatOptions override = OpenAiSdkChatOptions.builder().model("override-model").topP(0.9).build(); + + OpenAiSdkChatOptions merged = OpenAiSdkChatOptions.builder().from(base).merge(override).build(); + + // Model should be overridden + assertThat(merged.getModel()).isEqualTo("override-model"); + // Temperature should be preserved from base + assertThat(merged.getTemperature()).isEqualTo(0.5); + // MaxTokens should be preserved from base + assertThat(merged.getMaxTokens()).isEqualTo(100); + // TopP should come from override + assertThat(merged.getTopP()).isEqualTo(0.9); + } + + @Test + void testBuilderFrom() { + Map logitBias = Map.of("token", 1); + List stop = List.of("stop"); + Map metadata = Map.of("key", "value"); + + OpenAiSdkChatOptions source = OpenAiSdkChatOptions.builder() + .model("source-model") + .temperature(0.7) + .maxTokens(100) + .logitBias(logitBias) + .stop(stop) + .metadata(metadata) + .build(); + + OpenAiSdkChatOptions copy = OpenAiSdkChatOptions.builder().from(source).build(); + + assertThat(copy.getModel()).isEqualTo("source-model"); + assertThat(copy.getTemperature()).isEqualTo(0.7); + assertThat(copy.getMaxTokens()).isEqualTo(100); + assertThat(copy.getLogitBias()).isEqualTo(logitBias); + assertThat(copy.getStop()).isEqualTo(stop); + assertThat(copy.getMetadata()).isEqualTo(metadata); + // Verify collections are copied + assertThat(copy.getStop()).isNotSameAs(source.getStop()); + } + + @Test + void testMergeDoesNotOverrideWithNull() { + OpenAiSdkChatOptions base = OpenAiSdkChatOptions.builder() + .model("base-model") + .temperature(0.5) + .maxTokens(100) + .build(); + + OpenAiSdkChatOptions override = OpenAiSdkChatOptions.builder().model(null).temperature(null).build(); + + OpenAiSdkChatOptions merged = OpenAiSdkChatOptions.builder().from(base).merge(override).build(); + + // Null values should not override + assertThat(merged.getModel()).isEqualTo("base-model"); + assertThat(merged.getTemperature()).isEqualTo(0.5); + assertThat(merged.getMaxTokens()).isEqualTo(100); + } + + @Test + void testMergeWithEmptyCollections() { + ToolCallback callback = new ToolCallback() { + @Override + public org.springframework.ai.tool.definition.ToolDefinition getToolDefinition() { + return org.springframework.ai.tool.definition.DefaultToolDefinition.builder() + .name("tool") + .description("desc") + .inputSchema("{}") + .build(); + } + + @Override + public String call(String toolInput) { + return "result"; + } + }; + + OpenAiSdkChatOptions base = OpenAiSdkChatOptions.builder() + .toolCallbacks(callback) + .toolNames("tool1") + .toolContext(Map.of("key", "value")) + .build(); + + OpenAiSdkChatOptions override = new OpenAiSdkChatOptions(); + + OpenAiSdkChatOptions merged = OpenAiSdkChatOptions.builder().from(base).merge(override).build(); + + // Empty collections should not override + assertThat(merged.getToolCallbacks()).hasSize(1); + assertThat(merged.getToolNames()).hasSize(1); + assertThat(merged.getToolContext()).hasSize(1); + } + + @Test + void testToString() { + OpenAiSdkChatOptions options = OpenAiSdkChatOptions.builder().model("test-model").temperature(0.7).build(); + + String toString = options.toString(); + assertThat(toString).contains("OpenAiSdkChatOptions"); + assertThat(toString).contains("test-model"); + assertThat(toString).contains("0.7"); + } + + @Test + void testTopKReturnsNull() { + OpenAiSdkChatOptions options = new OpenAiSdkChatOptions(); + // TopK is not supported by OpenAI, should always return null + assertThat(options.getTopK()).isNull(); + } + +} diff --git a/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/chat/client/OpenAiSdkChatClientIT.java b/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/chat/client/OpenAiSdkChatClientIT.java new file mode 100644 index 00000000000..79d42be9d24 --- /dev/null +++ b/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/chat/client/OpenAiSdkChatClientIT.java @@ -0,0 +1,527 @@ +/* + * 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.openaisdk.chat.client; + +import java.io.IOException; +import java.net.URL; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import com.openai.models.chat.completions.ChatCompletionCreateParams.Modality; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; + +import org.springframework.ai.chat.client.AdvisorParams; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.StreamingChatModel; +import org.springframework.ai.converter.BeanOutputConverter; +import org.springframework.ai.converter.ListOutputConverter; +import org.springframework.ai.openaisdk.OpenAiSdkChatModel; +import org.springframework.ai.openaisdk.OpenAiSdkChatOptions; +import org.springframework.ai.openaisdk.OpenAiSdkChatOptions.AudioParameters; +import org.springframework.ai.openaisdk.OpenAiSdkChatOptions.StreamOptions; +import org.springframework.ai.openaisdk.OpenAiSdkTestConfiguration; +import org.springframework.ai.openaisdk.chat.MockWeatherService; +import org.springframework.ai.template.st.StTemplateRenderer; +import org.springframework.ai.test.CurlyBracketEscaper; +import org.springframework.ai.tool.function.FunctionToolCallback; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.util.MimeTypeUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.fail; + +@SpringBootTest(classes = OpenAiSdkTestConfiguration.class) +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") +@ActiveProfiles("logging-test") +@SuppressWarnings("null") +class OpenAiSdkChatClientIT { + + private static final Logger logger = LoggerFactory.getLogger(OpenAiSdkChatClientIT.class); + + @Autowired + protected ChatModel chatModel; + + @Autowired + protected StreamingChatModel streamingChatModel; + + @Autowired + protected OpenAiSdkChatModel openAiChatModel; + + @Value("classpath:/prompts/system-message.st") + private Resource systemTextResource; + + @Test + void call() { + + // @formatter:off + ChatResponse response = ChatClient.create(this.chatModel).prompt() + .advisors(new SimpleLoggerAdvisor()) + .system(s -> s.text(this.systemTextResource) + .param("name", "Bob") + .param("voice", "pirate")) + .user("Tell me about 3 famous pirates from the Golden Age of Piracy and what they did") + .call() + .chatResponse(); + // @formatter:on + + logger.info("" + response); + assertThat(response.getResults()).hasSize(1); + assertThat(response.getResults().get(0).getOutput().getText()).contains("Blackbeard"); + } + + @Test + void listOutputConverterString() { + // @formatter:off + List collection = ChatClient.create(this.chatModel).prompt() + .user(u -> u.text("List five {subject}") + .param("subject", "ice cream flavors")) + .call() + .entity(new ParameterizedTypeReference<>() { + }); + // @formatter:on + + logger.info(collection.toString()); + assertThat(collection).hasSize(5); + } + + @Test + void listOutputConverterBean() { + + // @formatter:off + List actorsFilms = ChatClient.create(this.chatModel).prompt() + .user("Generate the filmography of 5 movies for Tom Hanks and Bill Murray.") + .call() + .entity(new ParameterizedTypeReference<>() { + }); + // @formatter:on + + logger.info("" + actorsFilms); + assertThat(actorsFilms).hasSize(2); + } + + @Test + void customOutputConverter() { + + var toStringListConverter = new ListOutputConverter(new DefaultConversionService()); + + // @formatter:off + List flavors = ChatClient.create(this.chatModel).prompt() + .user(u -> u.text("List five {subject}") + .param("subject", "ice cream flavors")) + .call() + .entity(toStringListConverter); + // @formatter:on + + logger.info("ice cream flavors" + flavors); + assertThat(flavors).hasSize(5); + assertThat(flavors).containsAnyOf("Vanilla", "vanilla"); + } + + // @Test + void mapOutputConverter() { + // @formatter:off + Map result = ChatClient.create(this.chatModel).prompt() + .options(OpenAiSdkChatOptions.builder().model(com.openai.models.ChatModel.GPT_5_MINI.asString()).build()) + .user(u -> u.text("Provide me a List of {subject}") + .param("subject", "an array of numbers from 1 to 9 under they key name 'numbers'")) + .call() + .entity(new ParameterizedTypeReference<>() { + }); + // @formatter:on + + assertThat(result.get("numbers")).isEqualTo(Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9)); + } + + @Test + void beanOutputConverter() { + + // @formatter:off + ActorsFilms actorsFilms = ChatClient.create(this.chatModel).prompt() + .user("Generate the filmography for a random actor.") + .call() + .entity(ActorsFilms.class); + // @formatter:on + + logger.info("" + actorsFilms); + assertThat(actorsFilms.actor()).isNotBlank(); + } + + @Test + void beanOutputConverterNativeStructuredOutput() { + + // @formatter:off + ActorsFilms actorsFilms = ChatClient.create(this.chatModel).prompt() + .advisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT) + .user("Generate the filmography for a random actor.") + .call() + .entity(ActorsFilms.class); + // @formatter:on + + logger.info("" + actorsFilms); + assertThat(actorsFilms.actor()).isNotBlank(); + } + + @Test + void beanOutputConverterRecords() { + + // @formatter:off + ActorsFilms actorsFilms = ChatClient.create(this.chatModel).prompt() + .user("Generate the filmography of 5 movies for Tom Hanks.") + .call() + .entity(ActorsFilms.class); + // @formatter:on + + logger.info("" + actorsFilms); + assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks"); + assertThat(actorsFilms.movies()).hasSize(5); + } + + @Test + void beanOutputConverterRecordsNativeStructuredOutput() { + + // @formatter:off + ActorsFilms actorsFilms = ChatClient.create(this.chatModel).prompt() + .advisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT) + .user("Generate the filmography of 5 movies for Tom Hanks.") + .call() + .entity(ActorsFilms.class); + // @formatter:on + + logger.info("" + actorsFilms); + assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks"); + assertThat(actorsFilms.movies()).hasSize(5); + } + + @Test + void beanStreamOutputConverterRecords() { + + BeanOutputConverter outputConverter = new BeanOutputConverter<>(ActorsFilms.class); + + // @formatter:off + Flux chatResponse = ChatClient.create(this.chatModel) + .prompt() + .options(OpenAiSdkChatOptions.builder().streamOptions(StreamOptions.builder().includeUsage(true).build()).build()) + .advisors(new SimpleLoggerAdvisor()) + .user(u -> u + .text("Generate the filmography of 5 movies for Tom Hanks. " + System.lineSeparator() + + "{format}") + .param("format", CurlyBracketEscaper.escapeCurlyBrackets(outputConverter.getFormat()))) + .stream() + .chatResponse(); + + List chatResponses = chatResponse.collectList() + .block() + .stream() + .toList(); + + String generationTextFromStream = chatResponses + .stream() + .filter(cr -> cr.getResult() != null) + .map(cr -> cr.getResult().getOutput().getText()) + .filter(text -> text != null && !text.trim().isEmpty()) // Filter out empty/null text + .collect(Collectors.joining()); + // @formatter:on + + // Add debugging to understand what text we're trying to parse + logger.debug("Aggregated streaming text: {}", generationTextFromStream); + + // Ensure we have valid JSON before attempting conversion + if (generationTextFromStream.trim().isEmpty()) { + fail("Empty aggregated text from streaming response - this indicates a problem with streaming aggregation"); + } + + ActorsFilms actorsFilms = outputConverter.convert(generationTextFromStream); + + logger.info("" + actorsFilms); + assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks"); + assertThat(actorsFilms.movies()).hasSize(5); + } + + @Test + void functionCallTest() { + + // @formatter:off + String response = ChatClient.create(this.chatModel).prompt() + .user(u -> u.text("What's the weather like in San Francisco, Tokyo, and Paris in Celsius?")) + .toolCallbacks(FunctionToolCallback.builder("getCurrentWeather", new MockWeatherService()) + .description("Get the weather in location") + .inputType(MockWeatherService.Request.class) + .build()) + .call() + .content(); + // @formatter:on + + logger.info("Response: {}", response); + + assertThat(response).contains("30", "10", "15"); + } + + @Test + void defaultFunctionCallTest() { + + // @formatter:off + String response = ChatClient.builder(this.chatModel) + .defaultToolCallbacks(FunctionToolCallback.builder("getCurrentWeather", new MockWeatherService()) + .description("Get the weather in location") + .inputType(MockWeatherService.Request.class) + .build()) + .defaultUser(u -> u.text("What's the weather like in San Francisco, Tokyo, and Paris in Celsius?")) + .build() + .prompt().call().content(); + // @formatter:on + + logger.info("Response: {}", response); + + assertThat(response).contains("30", "10", "15"); + } + + @Test + void streamFunctionCallTest() { + + // @formatter:off + Flux response = ChatClient.create(this.chatModel).prompt() + .user("What's the weather like in San Francisco, Tokyo, and Paris in Celsius?") + .toolCallbacks(FunctionToolCallback.builder("getCurrentWeather", new MockWeatherService()) + .description("Get the weather in location") + .inputType(MockWeatherService.Request.class) + .build()) + .stream() + .content(); + // @formatter:on + + String content = response.collectList().block().stream().collect(Collectors.joining()); + logger.info("Response: {}", content); + + assertThat(content).contains("30", "10", "15"); + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "gpt-4o" }) + void multiModalityEmbeddedImage(String modelName) throws IOException { + + // @formatter:off + String response = ChatClient.create(this.chatModel).prompt() + .options(OpenAiSdkChatOptions.builder().model(modelName).build()) + .user(u -> u.text("Explain what do you see on this picture?") + .media(MimeTypeUtils.IMAGE_PNG, new ClassPathResource("/test.png"))) + .call() + .content(); + // @formatter:on + + logger.info(response); + assertThat(response).containsAnyOf("bananas", "apple", "bowl", "basket", "fruit stand"); + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "gpt-4o" }) + void multiModalityImageUrl(String modelName) throws IOException { + + URL url = new URL("https://docs.spring.io/spring-ai/reference/_images/multimodal.test.png"); + + // @formatter:off + String response = ChatClient.create(this.chatModel).prompt() + // TODO consider adding model(...) method to ChatClient as a shortcut to + .options(OpenAiSdkChatOptions.builder().model(modelName).build()) + .user(u -> u.text("Explain what do you see on this picture?").media(MimeTypeUtils.IMAGE_PNG, url)) + .call() + .content(); + // @formatter:on + + logger.info(response); + assertThat(response).containsAnyOf("bananas", "apple", "bowl", "basket", "fruit stand"); + } + + @Test + void streamingMultiModalityImageUrl() throws IOException { + + URL url = new URL("https://docs.spring.io/spring-ai/reference/_images/multimodal.test.png"); + + // @formatter:off + Flux response = ChatClient.create(this.chatModel).prompt() + .options(OpenAiSdkChatOptions.builder().model(com.openai.models.ChatModel.GPT_5_MINI.asString()) + .build()) + .user(u -> u.text("Explain what do you see on this picture?") + .media(MimeTypeUtils.IMAGE_PNG, url)) + .stream() + .content(); + // @formatter:on + + String content = response.collectList().block().stream().collect(Collectors.joining()); + + logger.info("Response: {}", content); + assertThat(content).containsAnyOf("bananas", "apple", "bowl", "basket", "fruit stand"); + } + + @Test + void multiModalityAudioResponse() { + + ChatResponse response = ChatClient.create(this.chatModel) + .prompt("Tell me joke about Spring Framework") + .options(OpenAiSdkChatOptions.builder() + .model(com.openai.models.ChatModel.GPT_4O_AUDIO_PREVIEW.asString()) + .outputAudio(new AudioParameters(AudioParameters.Voice.ALLOY, AudioParameters.AudioResponseFormat.WAV)) + .outputModalities(List.of(Modality.TEXT.asString(), Modality.AUDIO.asString())) + .build()) + .call() + .chatResponse(); + + assertThat(response).isNotNull(); + assertThat(response.getResult().getOutput().getMedia().get(0).getDataAsByteArray()).isNotEmpty(); + logger.info("Response: " + response); + } + + @Test + void customTemplateRendererWithCall() { + BeanOutputConverter outputConverter = new BeanOutputConverter<>(ActorsFilms.class); + + // @formatter:off + String result = ChatClient.create(this.chatModel).prompt() + .user(u -> u + .text("Generate the filmography of 5 movies for Tom Hanks. " + System.lineSeparator() + + "") + .param("format", outputConverter.getFormat())) + .templateRenderer(StTemplateRenderer.builder().startDelimiterToken('<').endDelimiterToken('>').build()) + .call() + .content(); + // @formatter:on + + assertThat(result).isNotEmpty(); + ActorsFilms actorsFilms = outputConverter.convert(result); + + logger.info("" + actorsFilms); + assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks"); + assertThat(actorsFilms.movies()).hasSize(5); + } + + @Test + void customTemplateRendererWithCallAndAdvisor() { + BeanOutputConverter outputConverter = new BeanOutputConverter<>(ActorsFilms.class); + + // @formatter:off + String result = ChatClient.create(this.chatModel).prompt() + .advisors(new SimpleLoggerAdvisor()) + .user(u -> u + .text("Generate the filmography of 5 movies for Tom Hanks. " + System.lineSeparator() + + "") + .param("format", outputConverter.getFormat())) + .templateRenderer(StTemplateRenderer.builder().startDelimiterToken('<').endDelimiterToken('>').build()) + .call() + .content(); + // @formatter:on + + assertThat(result).isNotEmpty(); + ActorsFilms actorsFilms = outputConverter.convert(result); + + logger.info("" + actorsFilms); + assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks"); + assertThat(actorsFilms.movies()).hasSize(5); + } + + @Test + void customTemplateRendererWithStream() { + BeanOutputConverter outputConverter = new BeanOutputConverter<>(ActorsFilms.class); + + // @formatter:off + Flux chatResponse = ChatClient.create(this.chatModel) + .prompt() + .options(OpenAiSdkChatOptions.builder().streamUsage(true).build()) + .user(u -> u + .text("Generate the filmography of 5 movies for Tom Hanks. " + System.lineSeparator() + + "") + .param("format", outputConverter.getFormat())) + .templateRenderer(StTemplateRenderer.builder().startDelimiterToken('<').endDelimiterToken('>').build()) + .stream() + .chatResponse(); + + List chatResponses = chatResponse.collectList() + .block() + .stream() + .toList(); + + String generationTextFromStream = chatResponses + .stream() + .filter(cr -> cr.getResult() != null) + .map(cr -> cr.getResult().getOutput().getText()) + .collect(Collectors.joining()); + // @formatter:on + + ActorsFilms actorsFilms = outputConverter.convert(generationTextFromStream); + + logger.info("" + actorsFilms); + assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks"); + assertThat(actorsFilms.movies()).hasSize(5); + } + + @Test + void customTemplateRendererWithStreamAndAdvisor() { + BeanOutputConverter outputConverter = new BeanOutputConverter<>(ActorsFilms.class); + + // @formatter:off + Flux chatResponse = ChatClient.create(this.chatModel) + .prompt() + .options(OpenAiSdkChatOptions.builder().streamUsage(true).build()) + .advisors(new SimpleLoggerAdvisor()) + .user(u -> u + .text("Generate the filmography of 5 movies for Tom Hanks. " + System.lineSeparator() + + "") + .param("format", outputConverter.getFormat())) + .templateRenderer(StTemplateRenderer.builder().startDelimiterToken('<').endDelimiterToken('>').build()) + .stream() + .chatResponse(); + + List chatResponses = chatResponse.collectList() + .block() + .stream() + .toList(); + + String generationTextFromStream = chatResponses + .stream() + .filter(cr -> cr.getResult() != null) + .map(cr -> cr.getResult().getOutput().getText()) + .collect(Collectors.joining()); + // @formatter:on + + ActorsFilms actorsFilms = outputConverter.convert(generationTextFromStream); + + logger.info("" + actorsFilms); + assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks"); + assertThat(actorsFilms.movies()).hasSize(5); + } + + record ActorsFilms(String actor, List movies) { + + } + +} diff --git a/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/embedding/OpenAiSdkEmbeddingIT.java b/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/embedding/OpenAiSdkEmbeddingIT.java new file mode 100644 index 00000000000..1afc2d46d11 --- /dev/null +++ b/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/embedding/OpenAiSdkEmbeddingIT.java @@ -0,0 +1,125 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.openaisdk.embedding; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import com.openai.models.embeddings.EmbeddingModel; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import org.springframework.ai.document.Document; +import org.springframework.ai.embedding.EmbeddingRequest; +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.ai.embedding.TokenCountBatchingStrategy; +import org.springframework.ai.openaisdk.OpenAiSdkEmbeddingModel; +import org.springframework.ai.openaisdk.OpenAiSdkEmbeddingOptions; +import org.springframework.ai.openaisdk.OpenAiSdkTestConfiguration; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.core.io.Resource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Integration tests for {@link OpenAiSdkEmbeddingModel}. + * + * @author Julien Dubois + */ +@SpringBootTest(classes = OpenAiSdkTestConfiguration.class) +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") +class OpenAiSdkEmbeddingIT { + + private final Resource resource = new DefaultResourceLoader().getResource("classpath:text_source.txt"); + + @Autowired + private OpenAiSdkEmbeddingModel openAiSdkEmbeddingModel; + + @Test + void defaultEmbedding() { + assertThat(this.openAiSdkEmbeddingModel).isNotNull(); + + EmbeddingResponse embeddingResponse = this.openAiSdkEmbeddingModel.embedForResponse(List.of("Hello World")); + assertThat(embeddingResponse.getResults()).hasSize(1); + assertThat(embeddingResponse.getResults().get(0)).isNotNull(); + assertThat(embeddingResponse.getResults().get(0).getOutput()).hasSize(1536); + assertThat(embeddingResponse.getMetadata().getUsage().getTotalTokens()).isEqualTo(2); + assertThat(embeddingResponse.getMetadata().getUsage().getPromptTokens()).isEqualTo(2); + + assertThat(this.openAiSdkEmbeddingModel.dimensions()).isEqualTo(1536); + assertThat(embeddingResponse.getMetadata().getModel()) + .contains(OpenAiSdkEmbeddingOptions.DEFAULT_EMBEDDING_MODEL); + } + + @Test + void embeddingBatchDocuments() throws Exception { + assertThat(this.openAiSdkEmbeddingModel).isNotNull(); + List embeddings = this.openAiSdkEmbeddingModel.embed( + List.of(new Document("Hello world"), new Document("Hello Spring"), new Document("Hello Spring AI!")), + OpenAiSdkEmbeddingOptions.builder().model(EmbeddingModel.TEXT_EMBEDDING_ADA_002.toString()).build(), + new TokenCountBatchingStrategy()); + assertThat(embeddings.size()).isEqualTo(3); + embeddings + .forEach(embedding -> assertThat(embedding.length).isEqualTo(this.openAiSdkEmbeddingModel.dimensions())); + } + + @Test + void embeddingBatchDocumentsThatExceedTheLimit() throws Exception { + assertThat(this.openAiSdkEmbeddingModel).isNotNull(); + String contentAsString = this.resource.getContentAsString(StandardCharsets.UTF_8); + assertThatThrownBy(() -> this.openAiSdkEmbeddingModel.embed( + List.of(new Document("Hello World"), new Document(contentAsString)), + OpenAiSdkEmbeddingOptions.builder().model(EmbeddingModel.TEXT_EMBEDDING_ADA_002.toString()).build(), + new TokenCountBatchingStrategy())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void embedding3Large() { + + EmbeddingResponse embeddingResponse = this.openAiSdkEmbeddingModel.call(new EmbeddingRequest( + List.of("Hello World"), + OpenAiSdkEmbeddingOptions.builder().model(EmbeddingModel.TEXT_EMBEDDING_3_LARGE.toString()).build())); + assertThat(embeddingResponse.getResults()).hasSize(1); + assertThat(embeddingResponse.getResults().get(0)).isNotNull(); + assertThat(embeddingResponse.getResults().get(0).getOutput()).hasSize(3072); + assertThat(embeddingResponse.getMetadata().getUsage().getTotalTokens()).isEqualTo(2); + assertThat(embeddingResponse.getMetadata().getUsage().getPromptTokens()).isEqualTo(2); + assertThat(embeddingResponse.getMetadata().getModel()) + .isEqualTo(EmbeddingModel.TEXT_EMBEDDING_3_LARGE.toString()); + } + + @Test + void textEmbeddingAda002() { + + EmbeddingResponse embeddingResponse = this.openAiSdkEmbeddingModel.call(new EmbeddingRequest( + List.of("Hello World"), + OpenAiSdkEmbeddingOptions.builder().model(EmbeddingModel.TEXT_EMBEDDING_3_SMALL.toString()).build())); + assertThat(embeddingResponse.getResults()).hasSize(1); + assertThat(embeddingResponse.getResults().get(0)).isNotNull(); + assertThat(embeddingResponse.getResults().get(0).getOutput()).hasSize(1536); + + assertThat(embeddingResponse.getMetadata().getUsage().getTotalTokens()).isEqualTo(2); + assertThat(embeddingResponse.getMetadata().getUsage().getPromptTokens()).isEqualTo(2); + assertThat(embeddingResponse.getMetadata().getModel()) + .isEqualTo(EmbeddingModel.TEXT_EMBEDDING_3_SMALL.toString()); + } + +} diff --git a/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/embedding/OpenAiSdkEmbeddingModelObservationIT.java b/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/embedding/OpenAiSdkEmbeddingModelObservationIT.java new file mode 100644 index 00000000000..1b90226a738 --- /dev/null +++ b/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/embedding/OpenAiSdkEmbeddingModelObservationIT.java @@ -0,0 +1,120 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.openaisdk.embedding; + +import java.util.List; + +import com.openai.models.embeddings.EmbeddingModel; +import io.micrometer.observation.tck.TestObservationRegistry; +import io.micrometer.observation.tck.TestObservationRegistryAssert; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import org.springframework.ai.document.MetadataMode; +import org.springframework.ai.embedding.EmbeddingRequest; +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.ai.embedding.EmbeddingResponseMetadata; +import org.springframework.ai.embedding.observation.DefaultEmbeddingModelObservationConvention; +import org.springframework.ai.embedding.observation.EmbeddingModelObservationDocumentation.HighCardinalityKeyNames; +import org.springframework.ai.embedding.observation.EmbeddingModelObservationDocumentation.LowCardinalityKeyNames; +import org.springframework.ai.observation.conventions.AiOperationType; +import org.springframework.ai.observation.conventions.AiProvider; +import org.springframework.ai.openaisdk.OpenAiSdkEmbeddingModel; +import org.springframework.ai.openaisdk.OpenAiSdkEmbeddingOptions; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for observation instrumentation in {@link OpenAiSdkEmbeddingModel}. + * + * @author Julien Dubois + */ +@SpringBootTest +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") +public class OpenAiSdkEmbeddingModelObservationIT { + + @Autowired + TestObservationRegistry observationRegistry; + + @Autowired + OpenAiSdkEmbeddingModel embeddingModel; + + @BeforeEach + void setUp() { + this.observationRegistry.clear(); + } + + @Test + void observationForEmbeddingOperation() { + var options = OpenAiSdkEmbeddingOptions.builder() + .model(EmbeddingModel.TEXT_EMBEDDING_3_SMALL.toString()) + .dimensions(1536) + .build(); + + EmbeddingRequest embeddingRequest = new EmbeddingRequest(List.of("Here comes the sun"), options); + + EmbeddingResponse embeddingResponse = this.embeddingModel.call(embeddingRequest); + assertThat(embeddingResponse.getResults()).isNotEmpty(); + + EmbeddingResponseMetadata responseMetadata = embeddingResponse.getMetadata(); + assertThat(responseMetadata).isNotNull(); + + TestObservationRegistryAssert.assertThat(this.observationRegistry) + .doesNotHaveAnyRemainingCurrentObservation() + .hasObservationWithNameEqualTo(DefaultEmbeddingModelObservationConvention.DEFAULT_NAME) + .that() + .hasContextualNameEqualTo("embedding " + EmbeddingModel.TEXT_EMBEDDING_3_SMALL) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.AI_OPERATION_TYPE.asString(), + AiOperationType.EMBEDDING.value()) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.AI_PROVIDER.asString(), AiProvider.OPENAI_SDK.value()) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.REQUEST_MODEL.asString(), + EmbeddingModel.TEXT_EMBEDDING_3_SMALL.toString()) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.RESPONSE_MODEL.asString(), responseMetadata.getModel()) + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.REQUEST_EMBEDDING_DIMENSIONS.asString(), "1536") + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.USAGE_INPUT_TOKENS.asString(), + String.valueOf(responseMetadata.getUsage().getPromptTokens())) + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.USAGE_TOTAL_TOKENS.asString(), + String.valueOf(responseMetadata.getUsage().getTotalTokens())) + .hasBeenStarted() + .hasBeenStopped(); + } + + @SpringBootConfiguration + static class Config { + + @Bean + public TestObservationRegistry observationRegistry() { + return TestObservationRegistry.create(); + } + + @Bean + public OpenAiSdkEmbeddingModel openAiEmbeddingModel(TestObservationRegistry observationRegistry) { + return new OpenAiSdkEmbeddingModel(MetadataMode.EMBED, + OpenAiSdkEmbeddingOptions.builder() + .model(OpenAiSdkEmbeddingOptions.DEFAULT_EMBEDDING_MODEL) + .build(), + observationRegistry); + } + + } + +} diff --git a/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/image/OpenAiSdkImageModelIT.java b/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/image/OpenAiSdkImageModelIT.java new file mode 100644 index 00000000000..46c5dbfe8b3 --- /dev/null +++ b/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/image/OpenAiSdkImageModelIT.java @@ -0,0 +1,85 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.openaisdk.image; + +import org.assertj.core.api.Assertions; +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.image.Image; +import org.springframework.ai.image.ImageOptionsBuilder; +import org.springframework.ai.image.ImagePrompt; +import org.springframework.ai.image.ImageResponse; +import org.springframework.ai.image.ImageResponseMetadata; +import org.springframework.ai.openaisdk.OpenAiSdkImageModel; +import org.springframework.ai.openaisdk.OpenAiSdkTestConfiguration; +import org.springframework.ai.openaisdk.metadata.OpenAiSdkImageGenerationMetadata; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link OpenAiSdkImageModel}. + * + * @author Julien Dubois + */ +@SpringBootTest(classes = OpenAiSdkTestConfiguration.class) +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") +public class OpenAiSdkImageModelIT { + + private final Logger logger = LoggerFactory.getLogger(OpenAiSdkImageModelIT.class); + + @Autowired + private OpenAiSdkImageModel imageModel; + + @Test + void imageAsUrlTest() { + var options = ImageOptionsBuilder.builder().height(1024).width(1024).build(); + + var instructions = """ + A cup of coffee at a restaurant table in Paris, France. + """; + + ImagePrompt imagePrompt = new ImagePrompt(instructions, options); + + ImageResponse imageResponse = this.imageModel.call(imagePrompt); + + assertThat(imageResponse.getResults()).hasSize(1); + + ImageResponseMetadata imageResponseMetadata = imageResponse.getMetadata(); + assertThat(imageResponseMetadata.getCreated()).isPositive(); + + var generation = imageResponse.getResult(); + Image image = generation.getOutput(); + assertThat(image.getUrl()).isNotEmpty(); + logger.info("Generated image URL: {}", image.getUrl()); + assertThat(image.getB64Json()).isNull(); + + var imageGenerationMetadata = generation.getMetadata(); + Assertions.assertThat(imageGenerationMetadata).isInstanceOf(OpenAiSdkImageGenerationMetadata.class); + + OpenAiSdkImageGenerationMetadata openAiSdkImageGenerationMetadata = (OpenAiSdkImageGenerationMetadata) imageGenerationMetadata; + + assertThat(openAiSdkImageGenerationMetadata).isNotNull(); + assertThat(openAiSdkImageGenerationMetadata.getRevisedPrompt()).isNotBlank(); + + } + +} diff --git a/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/image/OpenAiSdkImageModelObservationIT.java b/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/image/OpenAiSdkImageModelObservationIT.java new file mode 100644 index 00000000000..d507ef7838f --- /dev/null +++ b/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/image/OpenAiSdkImageModelObservationIT.java @@ -0,0 +1,122 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.openaisdk.image; + +import com.openai.models.images.ImageModel; +import io.micrometer.observation.tck.TestObservationRegistry; +import io.micrometer.observation.tck.TestObservationRegistryAssert; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import org.springframework.ai.image.ImagePrompt; +import org.springframework.ai.image.ImageResponse; +import org.springframework.ai.image.observation.DefaultImageModelObservationConvention; +import org.springframework.ai.image.observation.ImageModelObservationDocumentation; +import org.springframework.ai.observation.conventions.AiOperationType; +import org.springframework.ai.observation.conventions.AiProvider; +import org.springframework.ai.openaisdk.OpenAiSdkImageModel; +import org.springframework.ai.openaisdk.OpenAiSdkImageOptions; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for observation instrumentation in {@link OpenAiSdkImageModel}. + * + * @author Julien Dubois + */ +@SpringBootTest +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") +public class OpenAiSdkImageModelObservationIT { + + @Autowired + TestObservationRegistry observationRegistry; + + @Autowired + private OpenAiSdkImageModel imageModel; + + @BeforeEach + void setUp() { + this.observationRegistry.clear(); + } + + @Test + void observationForImageOperation() throws InterruptedException { + var options = OpenAiSdkImageOptions.builder() + .model(ImageModel.DALL_E_3.asString()) + .height(1024) + .width(1024) + .responseFormat("url") + .style("natural") + .build(); + + var instructions = """ + A cup of coffee at a restaurant table in Paris, France. + """; + + ImagePrompt imagePrompt = new ImagePrompt(instructions, options); + + ImageResponse imageResponse = this.imageModel.call(imagePrompt); + assertThat(imageResponse.getResults()).hasSize(1); + + Thread.sleep(200); // Wait for observation to be recorded + + TestObservationRegistryAssert.assertThat(this.observationRegistry) + .doesNotHaveAnyRemainingCurrentObservation() + .hasObservationWithNameEqualTo(DefaultImageModelObservationConvention.DEFAULT_NAME) + .that() + .hasContextualNameEqualTo("image " + ImageModel.DALL_E_3.asString()) + .hasLowCardinalityKeyValue( + ImageModelObservationDocumentation.LowCardinalityKeyNames.AI_OPERATION_TYPE.asString(), + AiOperationType.IMAGE.value()) + .hasLowCardinalityKeyValue(ImageModelObservationDocumentation.LowCardinalityKeyNames.AI_PROVIDER.asString(), + AiProvider.OPENAI_SDK.value()) + .hasLowCardinalityKeyValue( + ImageModelObservationDocumentation.LowCardinalityKeyNames.REQUEST_MODEL.asString(), + ImageModel.DALL_E_3.asString()) + .hasHighCardinalityKeyValue( + ImageModelObservationDocumentation.HighCardinalityKeyNames.REQUEST_IMAGE_SIZE.asString(), + "1024x1024") + .hasHighCardinalityKeyValue( + ImageModelObservationDocumentation.HighCardinalityKeyNames.REQUEST_IMAGE_RESPONSE_FORMAT.asString(), + "url") + .hasBeenStarted() + .hasBeenStopped(); + } + + @SpringBootConfiguration + static class Config { + + @Bean + public TestObservationRegistry observationRegistry() { + return TestObservationRegistry.create(); + } + + @Bean + public OpenAiSdkImageModel openAiImageModel(TestObservationRegistry observationRegistry) { + return new OpenAiSdkImageModel( + OpenAiSdkImageOptions.builder().model(OpenAiSdkImageOptions.DEFAULT_IMAGE_MODEL).build(), + observationRegistry); + } + + } + +} diff --git a/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/setup/OpenAiSdkSetupTests.java b/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/setup/OpenAiSdkSetupTests.java new file mode 100644 index 00000000000..70960424f68 --- /dev/null +++ b/models/spring-ai-openai-sdk/src/test/java/org/springframework/ai/openaisdk/setup/OpenAiSdkSetupTests.java @@ -0,0 +1,100 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.openaisdk.setup; + +import java.time.Duration; +import java.util.Collections; +import java.util.Map; + +import com.openai.client.OpenAIClient; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +public class OpenAiSdkSetupTests { + + @Test + void detectModelProvider_returnsAzureOpenAI_whenAzureFlagIsTrue() { + OpenAiSdkSetup.ModelProvider result = OpenAiSdkSetup.detectModelProvider(true, false, null, null, null); + + assertEquals(OpenAiSdkSetup.ModelProvider.MICROSOFT_FOUNDRY, result); + } + + @Test + void detectModelProvider_returnsGitHubModels_whenGitHubFlagIsTrue() { + OpenAiSdkSetup.ModelProvider result = OpenAiSdkSetup.detectModelProvider(false, true, null, null, null); + + assertEquals(OpenAiSdkSetup.ModelProvider.GITHUB_MODELS, result); + } + + @Test + void detectModelProvider_returnsAzureOpenAI_whenBaseUrlMatchesAzure() { + OpenAiSdkSetup.ModelProvider result = OpenAiSdkSetup.detectModelProvider(false, false, + "https://example.openai.azure.com", null, null); + + assertEquals(OpenAiSdkSetup.ModelProvider.MICROSOFT_FOUNDRY, result); + } + + @Test + void detectModelProvider_returnsGitHubModels_whenBaseUrlMatchesGitHub() { + OpenAiSdkSetup.ModelProvider result = OpenAiSdkSetup.detectModelProvider(false, false, + "https://models.github.ai/inference", null, null); + + assertEquals(OpenAiSdkSetup.ModelProvider.GITHUB_MODELS, result); + } + + @Test + void detectModelProvider_returnsOpenAI_whenNoConditionsMatch() { + OpenAiSdkSetup.ModelProvider result = OpenAiSdkSetup.detectModelProvider(false, false, null, null, null); + + assertEquals(OpenAiSdkSetup.ModelProvider.OPEN_AI, result); + } + + @Test + void setupSyncClient_returnsClient_whenValidApiKeyProvided() { + OpenAIClient client = OpenAiSdkSetup.setupSyncClient(null, "valid-api-key", null, null, null, null, false, + false, null, Duration.ofSeconds(30), 2, null, null); + + assertNotNull(client); + } + + @Test + void setupSyncClient_appliesCustomHeaders_whenProvided() { + Map customHeaders = Collections.singletonMap("X-Custom-Header", "value"); + + OpenAIClient client = OpenAiSdkSetup.setupSyncClient(null, "valid-api-key", null, null, null, null, false, + false, null, Duration.ofSeconds(30), 2, null, customHeaders); + + assertNotNull(client); + } + + @Test + void calculateBaseUrl_returnsDefaultOpenAIUrl_whenBaseUrlIsNull() { + String result = OpenAiSdkSetup.calculateBaseUrl(null, OpenAiSdkSetup.ModelProvider.OPEN_AI, null, null); + + assertEquals(OpenAiSdkSetup.OPENAI_URL, result); + } + + @Test + void calculateBaseUrl_returnsGitHubUrl_whenModelHostIsGitHub() { + String result = OpenAiSdkSetup.calculateBaseUrl(null, OpenAiSdkSetup.ModelProvider.GITHUB_MODELS, null, null); + + assertEquals(OpenAiSdkSetup.GITHUB_MODELS_URL, result); + } + +} diff --git a/models/spring-ai-openai-sdk/src/test/resources/prompts/system-message.st b/models/spring-ai-openai-sdk/src/test/resources/prompts/system-message.st new file mode 100644 index 00000000000..dd95164675f --- /dev/null +++ b/models/spring-ai-openai-sdk/src/test/resources/prompts/system-message.st @@ -0,0 +1,4 @@ +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}. \ No newline at end of file diff --git a/models/spring-ai-openai-sdk/src/test/resources/test.png b/models/spring-ai-openai-sdk/src/test/resources/test.png new file mode 100644 index 00000000000..8abb4c81aea Binary files /dev/null and b/models/spring-ai-openai-sdk/src/test/resources/test.png differ diff --git a/models/spring-ai-openai-sdk/src/test/resources/text_source.txt b/models/spring-ai-openai-sdk/src/test/resources/text_source.txt new file mode 100644 index 00000000000..5f777418da0 --- /dev/null +++ b/models/spring-ai-openai-sdk/src/test/resources/text_source.txt @@ -0,0 +1,4124 @@ + + Spring Framework Documentation + + + Version 6.0.0 + + Chapter 1. Spring Framework Overview + + + Spring makes it easy to create Java enterprise applications. It provides everything you need to + embrace the Java language in an enterprise environment, with support for Groovy and Kotlin as + alternative languages on the JVM, and with the flexibility to create many kinds of architectures + depending on an application’s needs. As of Spring Framework 5.1, Spring requires JDK 8+ (Java SE + 8+) and provides out-of-the-box support for JDK 11 LTS. Java SE 8 update 60 is suggested as the + minimum patch release for Java 8, but it is generally recommended to use a recent patch release. + + Spring supports a wide range of application scenarios. In a large enterprise, applications often exist + for a long time and have to run on a JDK and application server whose upgrade cycle is beyond + developer control. Others may run as a single jar with the server embedded, possibly in a cloud + environment. Yet others may be standalone applications (such as batch or integration workloads) + that do not need a server. + + + Spring is open source. It has a large and active community that provides continuous feedback based + on a diverse range of real-world use cases. This has helped Spring to successfully evolve over a very + long time. + + 1.1. What We Mean by "Spring" + + + The term "Spring" means different things in different contexts. It can be used to refer to the Spring + Framework project itself, which is where it all started. Over time, other Spring projects have been + built on top of the Spring Framework. Most often, when people say "Spring", they mean the entire + family of projects. This reference documentation focuses on the foundation: the Spring Framework + itself. + + + The Spring Framework is divided into modules. Applications can choose which modules they need. + At the heart are the modules of the core container, including a configuration model and a + dependency injection mechanism. Beyond that, the Spring Framework provides foundational + support for different application architectures, including messaging, transactional data and + persistence, and web. It also includes the Servlet-based Spring MVC web framework and, in + parallel, the Spring WebFlux reactive web framework. + + + A note about modules: Spring’s framework jars allow for deployment to JDK 9’s module path + ("Jigsaw"). For use in Jigsaw-enabled applications, the Spring Framework 5 jars come with + "Automatic-Module-Name" manifest entries which define stable language-level module names + ("spring.core", "spring.context", etc.) independent from jar artifact names (the jars follow the same + naming pattern with "-" instead of ".", e.g. "spring-core" and "spring-context"). Of course, Spring’s + framework jars keep working fine on the classpath on both JDK 8 and 9+. + + 1.2. History of Spring and the Spring Framework + + + Spring came into being in 2003 as a response to the complexity of the early J2EE specifications. + While some consider Java EE and its modern-day successor Jakarta EE to be in competition with + Spring, they are in fact complementary. The Spring programming model does not embrace the + Jakarta EE platform specification; rather, it integrates with carefully selected individual + + specifications from the traditional EE umbrella: + + + • Servlet API (JSR 340) + + • WebSocket API (JSR 356) + + • Concurrency Utilities (JSR 236) + + • JSON Binding API (JSR 367) + + • Bean Validation (JSR 303) + + • JPA (JSR 338) + + • JMS (JSR 914) + + • as well as JTA/JCA setups for transaction coordination, if necessary. + + + The Spring Framework also supports the Dependency Injection (JSR 330) and Common Annotations + (JSR 250) specifications, which application developers may choose to use instead of the Spring- + specific mechanisms provided by the Spring Framework. Originally, those were based on common + javax packages. + + As of Spring Framework 6.0, Spring has been upgraded to the Jakarta EE 9 level (e.g. Servlet 5.0+, + JPA 3.0+), based on the jakarta namespace instead of the traditional javax packages. With EE 9 as + the minimum and EE 10 supported already, Spring is prepared to provide out-of-the-box support + for the further evolution of the Jakarta EE APIs. Spring Framework 6.0 is fully compatible with + Tomcat 10.1, Jetty 11 and Undertow 2.3 as web servers, and also with Hibernate ORM 6.1. + + + Over time, the role of Java/Jakarta EE in application development has evolved. In the early days of + J2EE and Spring, applications were created to be deployed to an application server. Today, with the + help of Spring Boot, applications are created in a devops- and cloud-friendly way, with the Servlet + container embedded and trivial to change. As of Spring Framework 5, a WebFlux application does + not even use the Servlet API directly and can run on servers (such as Netty) that are not Servlet + containers. + + + Spring continues to innovate and to evolve. Beyond the Spring Framework, there are other projects, + such as Spring Boot, Spring Security, Spring Data, Spring Cloud, Spring Batch, among others. It’s + important to remember that each project has its own source code repository, issue tracker, and + release cadence. See spring.io/projects for the complete list of Spring projects. + + 1.3. Design Philosophy + + + When you learn about a framework, it’s important to know not only what it does but what + principles it follows. Here are the guiding principles of the Spring Framework: + + + • Provide choice at every level. Spring lets you defer design decisions as late as possible. For + example, you can switch persistence providers through configuration without changing your + code. The same is true for many other infrastructure concerns and integration with third-party + APIs. + + • Accommodate diverse perspectives. Spring embraces flexibility and is not opinionated about + how things should be done. It supports a wide range of application needs with different + perspectives. + + • Maintain strong backward compatibility. Spring’s evolution has been carefully managed to + force few breaking changes between versions. Spring supports a carefully chosen range of JDK + versions and third-party libraries to facilitate maintenance of applications and libraries that + depend on Spring. + + • Care about API design. The Spring team puts a lot of thought and time into making APIs that are + intuitive and that hold up across many versions and many years. + + • Set high standards for code quality. The Spring Framework puts a strong emphasis on + meaningful, current, and accurate javadoc. It is one of very few projects that can claim clean + code structure with no circular dependencies between packages. + + 1.4. Feedback and Contributions + + + For how-to questions or diagnosing or debugging issues, we suggest using Stack Overflow. Click + here for a list of the suggested tags to use on Stack Overflow. If you’re fairly certain that there is a + problem in the Spring Framework or would like to suggest a feature, please use the GitHub Issues. + + If you have a solution in mind or a suggested fix, you can submit a pull request on Github. + However, please keep in mind that, for all but the most trivial issues, we expect a ticket to be filed + in the issue tracker, where discussions take place and leave a record for future reference. + + + For more details see the guidelines at the CONTRIBUTING, top-level project page. + + 1.5. Getting Started + + + If you are just getting started with Spring, you may want to begin using the Spring Framework by + creating a Spring Boot-based application. Spring Boot provides a quick (and opinionated) way to + create a production-ready Spring-based application. It is based on the Spring Framework, favors + convention over configuration, and is designed to get you up and running as quickly as possible. + + + You can use start.spring.io to generate a basic project or follow one of the "Getting Started" guides, + such as Getting Started Building a RESTful Web Service. As well as being easier to digest, these + guides are very task focused, and most of them are based on Spring Boot. They also cover other + projects from the Spring portfolio that you might want to consider when solving a particular + problem. + + Chapter 2. Core Technologies + + + This part of the reference documentation covers all the technologies that are absolutely integral to + the Spring Framework. + + + Foremost amongst these is the Spring Framework’s Inversion of Control (IoC) container. A thorough + treatment of the Spring Framework’s IoC container is closely followed by comprehensive coverage + of Spring’s Aspect-Oriented Programming (AOP) technologies. The Spring Framework has its own + AOP framework, which is conceptually easy to understand and which successfully addresses the + 80% sweet spot of AOP requirements in Java enterprise programming. + + + Coverage of Spring’s integration with AspectJ (currently the richest — in terms of features — and + certainly most mature AOP implementation in the Java enterprise space) is also provided. + + + AOT processing can be used to optimize your application ahead-of-time. It is typically used for + native image deployment using GraalVM. + + 2.1. The IoC Container + + + This chapter covers Spring’s Inversion of Control (IoC) container. + + + 2.1.1. Introduction to the Spring IoC Container and Beans + + This chapter covers the Spring Framework implementation of the Inversion of Control (IoC) + principle. IoC is also known as dependency injection (DI). It is a process whereby objects define + their dependencies (that is, the other objects they work with) only through constructor arguments, + arguments to a factory method, or properties that are set on the object instance after it is + constructed or returned from a factory method. The container then injects those dependencies + when it creates the bean. This process is fundamentally the inverse (hence the name, Inversion of + Control) of the bean itself controlling the instantiation or location of its dependencies by using + direct construction of classes or a mechanism such as the Service Locator pattern. + + + The org.springframework.beans and org.springframework.context packages are the basis for Spring + Framework’s IoC container. The BeanFactory interface provides an advanced configuration + mechanism capable of managing any type of object. ApplicationContext is a sub-interface of + BeanFactory. It adds: + + + • Easier integration with Spring’s AOP features + + • Message resource handling (for use in internationalization) + + • Event publication + + • Application-layer specific contexts such as the WebApplicationContext for use in web + applications. + + + In short, the BeanFactory provides the configuration framework and basic functionality, and the + ApplicationContext adds more enterprise-specific functionality. The ApplicationContext is a + complete superset of the BeanFactory and is used exclusively in this chapter in descriptions of + Spring’s IoC container. For more information on using the BeanFactory instead of the + + ApplicationContext, see the section covering the BeanFactory API. + + + In Spring, the objects that form the backbone of your application and that are managed by the + Spring IoC container are called beans. A bean is an object that is instantiated, assembled, and + managed by a Spring IoC container. Otherwise, a bean is simply one of many objects in your + application. Beans, and the dependencies among them, are reflected in the configuration metadata + used by a container. + + + 2.1.2. Container Overview + + The org.springframework.context.ApplicationContext interface represents the Spring IoC container + and is responsible for instantiating, configuring, and assembling the beans. The container gets its + instructions on what objects to instantiate, configure, and assemble by reading configuration + metadata. The configuration metadata is represented in XML, Java annotations, or Java code. It lets + you express the objects that compose your application and the rich interdependencies between + those objects. + + + Several implementations of the ApplicationContext interface are supplied with Spring. In stand- + alone applications, it is common to create an instance of ClassPathXmlApplicationContext or + FileSystemXmlApplicationContext. While XML has been the traditional format for defining + configuration metadata, you can instruct the container to use Java annotations or code as the + metadata format by providing a small amount of XML configuration to declaratively enable support + for these additional metadata formats. + + + In most application scenarios, explicit user code is not required to instantiate one or more + instances of a Spring IoC container. For example, in a web application scenario, a simple eight (or + so) lines of boilerplate web descriptor XML in the web.xml file of the application typically suffices + (see Convenient ApplicationContext Instantiation for Web Applications). If you use the Spring Tools + for Eclipse (an Eclipse-powered development environment), you can easily create this boilerplate + configuration with a few mouse clicks or keystrokes. + + + The following diagram shows a high-level view of how Spring works. Your application classes are + combined with configuration metadata so that, after the ApplicationContext is created and + initialized, you have a fully configured and executable system or application. + + Figure 1. The Spring IoC container + + + Configuration Metadata + + As the preceding diagram shows, the Spring IoC container consumes a form of configuration + metadata. This configuration metadata represents how you, as an application developer, tell the + Spring container to instantiate, configure, and assemble the objects in your application. + + + Configuration metadata is traditionally supplied in a simple and intuitive XML format, which is + what most of this chapter uses to convey key concepts and features of the Spring IoC container. + + + XML-based metadata is not the only allowed form of configuration metadata. The + Spring IoC container itself is totally decoupled from the format in which this +  configuration metadata is actually written. These days, many developers choose + Java-based configuration for their Spring applications. + + + For information about using other forms of metadata with the Spring container, see: + + + • Annotation-based configuration: Spring 2.5 introduced support for annotation-based + configuration metadata. + + • Java-based configuration: Starting with Spring 3.0, many features provided by the Spring + JavaConfig project became part of the core Spring Framework. Thus, you can define beans + external to your application classes by using Java rather than XML files. To use these new + features, see the @Configuration, @Bean, @Import, and @DependsOn annotations. + + Spring configuration consists of at least one and typically more than one bean definition that the + container must manage. XML-based configuration metadata configures these beans as + elements inside a top-level element. Java configuration typically uses @Bean-annotated + methods within a @Configuration class. + + These bean definitions correspond to the actual objects that make up your application. Typically, + you define service layer objects, data access objects (DAOs), presentation objects such as Struts + Action instances, infrastructure objects such as Hibernate SessionFactories, JMS Queues, and so + forth. Typically, one does not configure fine-grained domain objects in the container, because it is + + usually the responsibility of DAOs and business logic to create and load domain objects. However, + you can use Spring’s integration with AspectJ to configure objects that have been created outside + the control of an IoC container. See Using AspectJ to dependency-inject domain objects with Spring. + + + The following example shows the basic structure of XML-based configuration metadata: + + + + + + + +   ① ② +   +   + + +   +   +   + + +   + + + + + + ① The id attribute is a string that identifies the individual bean definition. + + ② The class attribute defines the type of the bean and uses the fully qualified classname. + + The value of the id attribute refers to collaborating objects. The XML for referring to collaborating + objects is not shown in this example. See Dependencies for more information. + + + Instantiating a Container + + The location path or paths supplied to an ApplicationContext constructor are resource strings that + let the container load configuration metadata from a variety of external resources, such as the local + file system, the Java CLASSPATH, and so on. + + + Java + + + ApplicationContext context = new ClassPathXmlApplicationContext("services.xml", + "daos.xml"); + + + + Kotlin + + + val context = ClassPathXmlApplicationContext("services.xml", "daos.xml") + + After you learn about Spring’s IoC container, you may want to know more about + Spring’s Resource abstraction (as described in Resources), which provides a +  convenient mechanism for reading an InputStream from locations defined in a + URI syntax. In particular, Resource paths are used to construct applications + contexts, as described in Application Contexts and Resource Paths. + + + The following example shows the service layer objects (services.xml) configuration file: + + + + + + + +   + + +   +   +   +   +   + + +   + + + + + + + The following example shows the data access objects daos.xml file: + + + + + + + +   +   +   + + +   +   +   + + +   + + + + + In the preceding example, the service layer consists of the PetStoreServiceImpl class and two data + access objects of the types JpaAccountDao and JpaItemDao (based on the JPA Object-Relational + Mapping standard). The property name element refers to the name of the JavaBean property, and the + ref element refers to the name of another bean definition. This linkage between id and ref + elements expresses the dependency between collaborating objects. For details of configuring an + object’s dependencies, see Dependencies. + + + + Composing XML-based Configuration Metadata + + It can be useful to have bean definitions span multiple XML files. Often, each individual XML + configuration file represents a logical layer or module in your architecture. + + + You can use the application context constructor to load bean definitions from all these XML + fragments. This constructor takes multiple Resource locations, as was shown in the previous section. + Alternatively, use one or more occurrences of the element to load bean definitions from + another file or files. The following example shows how to do so: + + + + +   +   +   + + +   +   + + + + + In the preceding example, external bean definitions are loaded from three files: services.xml, + messageSource.xml, and themeSource.xml. All location paths are relative to the definition file doing + the importing, so services.xml must be in the same directory or classpath location as the file doing + the importing, while messageSource.xml and themeSource.xml must be in a resources location below + the location of the importing file. As you can see, a leading slash is ignored. However, given that + these paths are relative, it is better form not to use the slash at all. The contents of the files being + imported, including the top level element, must be valid XML bean definitions, according + to the Spring Schema. + + It is possible, but not recommended, to reference files in parent directories using a + relative "../" path. Doing so creates a dependency on a file that is outside the + current application. In particular, this reference is not recommended for + classpath: URLs (for example, classpath:../services.xml), where the runtime + resolution process chooses the “nearest” classpath root and then looks into its + parent directory. Classpath configuration changes may lead to the choice of a + different, incorrect directory. +  + You can always use fully qualified resource locations instead of relative paths: for + example, file:C:/config/services.xml or classpath:/config/services.xml. + However, be aware that you are coupling your application’s configuration to + specific absolute locations. It is generally preferable to keep an indirection for such + absolute locations — for example, through "${…}" placeholders that are resolved + against JVM system properties at runtime. + + + The namespace itself provides the import directive feature. Further configuration features beyond + plain bean definitions are available in a selection of XML namespaces provided by Spring — for + example, the context and util namespaces. + + + + The Groovy Bean Definition DSL + + As a further example for externalized configuration metadata, bean definitions can also be + expressed in Spring’s Groovy Bean Definition DSL, as known from the Grails framework. Typically, + such configuration live in a ".groovy" file with the structure shown in the following example: + + + + beans { +   dataSource(BasicDataSource) { +   driverClassName = "org.hsqldb.jdbcDriver" +   url = "jdbc:hsqldb:mem:grailsDB" +   username = "sa" +   password = "" +   settings = [mynew:"setting"] +   } +   sessionFactory(SessionFactory) { +   dataSource = dataSource +   } +   myService(MyService) { +   nestedBean = { AnotherBean bean -> +   dataSource = dataSource +   } +   } + } + + + + This configuration style is largely equivalent to XML bean definitions and even supports Spring’s + XML configuration namespaces. It also allows for importing XML bean definition files through an + importBeans directive. + + Using the Container + + The ApplicationContext is the interface for an advanced factory capable of maintaining a registry of + different beans and their dependencies. By using the method T getBean(String name, Class + requiredType), you can retrieve instances of your beans. + + The ApplicationContext lets you read bean definitions and access them, as the following example + shows: + + + Java + + + // create and configure beans + ApplicationContext context = new ClassPathXmlApplicationContext("services.xml", + "daos.xml"); + + + // retrieve configured instance + PetStoreService service = context.getBean("petStore", PetStoreService.class); + + + // use configured instance + List userList = service.getUsernameList(); + + + + Kotlin + + + import org.springframework.beans.factory.getBean + + + // create and configure beans + val context = ClassPathXmlApplicationContext("services.xml", "daos.xml") + + + // retrieve configured instance + val service = context.getBean("petStore") + + + // use configured instance + var userList = service.getUsernameList() + + + + With Groovy configuration, bootstrapping looks very similar. It has a different context + implementation class which is Groovy-aware (but also understands XML bean definitions). The + following example shows Groovy configuration: + + + Java + + + ApplicationContext context = new GenericGroovyApplicationContext("services.groovy", + "daos.groovy"); + + + + Kotlin + + + val context = GenericGroovyApplicationContext("services.groovy", "daos.groovy") + + + + The most flexible variant is GenericApplicationContext in combination with reader delegates — for + example, with XmlBeanDefinitionReader for XML files, as the following example shows: + + Java + + + GenericApplicationContext context = new GenericApplicationContext(); + new XmlBeanDefinitionReader(context).loadBeanDefinitions("services.xml", "daos.xml"); + context.refresh(); + + + + Kotlin + + + val context = GenericApplicationContext() + XmlBeanDefinitionReader(context).loadBeanDefinitions("services.xml", "daos.xml") + context.refresh() + + + + You can also use the GroovyBeanDefinitionReader for Groovy files, as the following example shows: + + + Java + + + GenericApplicationContext context = new GenericApplicationContext(); + new GroovyBeanDefinitionReader(context).loadBeanDefinitions("services.groovy", + "daos.groovy"); + context.refresh(); + + + + Kotlin + + + val context = GenericApplicationContext() + GroovyBeanDefinitionReader(context).loadBeanDefinitions("services.groovy", + "daos.groovy") + context.refresh() + + + + You can mix and match such reader delegates on the same ApplicationContext, reading bean + definitions from diverse configuration sources. + + + You can then use getBean to retrieve instances of your beans. The ApplicationContext interface has a + few other methods for retrieving beans, but, ideally, your application code should never use them. + Indeed, your application code should have no calls to the getBean() method at all and thus have no + dependency on Spring APIs at all. For example, Spring’s integration with web frameworks provides + dependency injection for various web framework components such as controllers and JSF-managed + beans, letting you declare a dependency on a specific bean through metadata (such as an + autowiring annotation). + + + 2.1.3. Bean Overview + + A Spring IoC container manages one or more beans. These beans are created with the configuration + metadata that you supply to the container (for example, in the form of XML definitions). + + + Within the container itself, these bean definitions are represented as BeanDefinition objects, which + contain (among other information) the following metadata: + + • A package-qualified class name: typically, the actual implementation class of the bean being + + defined. + + • Bean behavioral configuration elements, which state how the bean should behave in the + container (scope, lifecycle callbacks, and so forth). + + • References to other beans that are needed for the bean to do its work. These references are also + called collaborators or dependencies. + + • Other configuration settings to set in the newly created object — for example, the size limit of + the pool or the number of connections to use in a bean that manages a connection pool. + + + This metadata translates to a set of properties that make up each bean definition. The following + table describes these properties: + + + Table 1. The bean definition + + Property Explained in… + + Class Instantiating Beans + + Name Naming Beans + + Scope Bean Scopes + + Constructor arguments Dependency Injection + + Properties Dependency Injection + + Autowiring mode Autowiring Collaborators + + Lazy initialization mode Lazy-initialized Beans + + Initialization method Initialization Callbacks + + Destruction method Destruction Callbacks + + + In addition to bean definitions that contain information on how to create a specific bean, the + ApplicationContext implementations also permit the registration of existing objects that are created + outside the container (by users). This is done by accessing the ApplicationContext’s BeanFactory + through the getBeanFactory() method, which returns the DefaultListableBeanFactory + implementation. DefaultListableBeanFactory supports this registration through the + registerSingleton(..) and registerBeanDefinition(..) methods. However, typical applications + work solely with beans defined through regular bean definition metadata. + + + Bean metadata and manually supplied singleton instances need to be registered as + early as possible, in order for the container to properly reason about them during + autowiring and other introspection steps. While overriding existing metadata and +  existing singleton instances is supported to some degree, the registration of new + beans at runtime (concurrently with live access to the factory) is not officially + supported and may lead to concurrent access exceptions, inconsistent state in the + bean container, or both. + + + + Naming Beans + + Every bean has one or more identifiers. These identifiers must be unique within the container that + hosts the bean. A bean usually has only one identifier. However, if it requires more than one, the + + extra ones can be considered aliases. + + + In XML-based configuration metadata, you use the id attribute, the name attribute, or both to specify + the bean identifiers. The id attribute lets you specify exactly one id. Conventionally, these names + are alphanumeric ('myBean', 'someService', etc.), but they can contain special characters as well. If + you want to introduce other aliases for the bean, you can also specify them in the name attribute, + separated by a comma (,), semicolon (;), or white space. As a historical note, in versions prior to + Spring 3.1, the id attribute was defined as an xsd:ID type, which constrained possible characters. As + of 3.1, it is defined as an xsd:string type. Note that bean id uniqueness is still enforced by the + container, though no longer by XML parsers. + + + You are not required to supply a name or an id for a bean. If you do not supply a name or id explicitly, + the container generates a unique name for that bean. However, if you want to refer to that bean by + name, through the use of the ref element or a Service Locator style lookup, you must provide a + name. Motivations for not supplying a name are related to using inner beans and autowiring + collaborators. + + + Bean Naming Conventions + + The convention is to use the standard Java convention for instance field names when naming + beans. That is, bean names start with a lowercase letter and are camel-cased from there. + Examples of such names include accountManager, accountService, userDao, loginController, and + so forth. + + + Naming beans consistently makes your configuration easier to read and understand. Also, if + you use Spring AOP, it helps a lot when applying advice to a set of beans related by name. + + + + + With component scanning in the classpath, Spring generates bean names for + unnamed components, following the rules described earlier: essentially, taking the + simple class name and turning its initial character to lower-case. However, in the +  (unusual) special case when there is more than one character and both the first + and second characters are upper case, the original casing gets preserved. These are + the same rules as defined by java.beans.Introspector.decapitalize (which Spring + uses here). + + + + Aliasing a Bean outside the Bean Definition + + In a bean definition itself, you can supply more than one name for the bean, by using a + combination of up to one name specified by the id attribute and any number of other names in the + name attribute. These names can be equivalent aliases to the same bean and are useful for some + situations, such as letting each component in an application refer to a common dependency by + using a bean name that is specific to that component itself. + + Specifying all aliases where the bean is actually defined is not always adequate, however. It is + sometimes desirable to introduce an alias for a bean that is defined elsewhere. This is commonly + the case in large systems where configuration is split amongst each subsystem, with each + subsystem having its own set of object definitions. In XML-based configuration metadata, you can + use the element to accomplish this. The following example shows how to do so: + + + + + + In this case, a bean (in the same container) named fromName may also, after the use of this alias + definition, be referred to as toName. + + + For example, the configuration metadata for subsystem A may refer to a DataSource by the name of + subsystemA-dataSource. The configuration metadata for subsystem B may refer to a DataSource by + the name of subsystemB-dataSource. When composing the main application that uses both these + subsystems, the main application refers to the DataSource by the name of myApp-dataSource. To have + all three names refer to the same object, you can add the following alias definitions to the + configuration metadata: + + + + + + + + + Now each component and the main application can refer to the dataSource through a name that is + unique and guaranteed not to clash with any other definition (effectively creating a namespace), + yet they refer to the same bean. + + + Java-configuration + + If you use Javaconfiguration, the @Bean annotation can be used to provide aliases. See Using + the @Bean Annotation for details. + + + + + Instantiating Beans + + A bean definition is essentially a recipe for creating one or more objects. The container looks at the + recipe for a named bean when asked and uses the configuration metadata encapsulated by that + bean definition to create (or acquire) an actual object. + + + If you use XML-based configuration metadata, you specify the type (or class) of object that is to be + instantiated in the class attribute of the element. This class attribute (which, internally, is a + Class property on a BeanDefinition instance) is usually mandatory. (For exceptions, see + Instantiation by Using an Instance Factory Method and Bean Definition Inheritance.) You can use + the Class property in one of two ways: + + + • Typically, to specify the bean class to be constructed in the case where the container itself + directly creates the bean by calling its constructor reflectively, somewhat equivalent to Java + code with the new operator. + + • To specify the actual class containing the static factory method that is invoked to create the + object, in the less common case where the container invokes a static factory method on a class + to create the bean. The object type returned from the invocation of the static factory method + may be the same class or another class entirely. + + Nested class names + + If you want to configure a bean definition for a nested class, you may use either the binary + name or the source name of the nested class. + + + For example, if you have a class called SomeThing in the com.example package, and this + SomeThing class has a static nested class called OtherThing, they can be separated by a dollar + sign ($) or a dot (.). So the value of the class attribute in a bean definition would be + com.example.SomeThing$OtherThing or com.example.SomeThing.OtherThing. + + + + + + Instantiation with a Constructor + + When you create a bean by the constructor approach, all normal classes are usable by and + compatible with Spring. That is, the class being developed does not need to implement any specific + interfaces or to be coded in a specific fashion. Simply specifying the bean class should suffice. + However, depending on what type of IoC you use for that specific bean, you may need a default + (empty) constructor. + + + The Spring IoC container can manage virtually any class you want it to manage. It is not limited to + managing true JavaBeans. Most Spring users prefer actual JavaBeans with only a default (no- + argument) constructor and appropriate setters and getters modeled after the properties in the + container. You can also have more exotic non-bean-style classes in your container. If, for example, + you need to use a legacy connection pool that absolutely does not adhere to the JavaBean + specification, Spring can manage it as well. + + + With XML-based configuration metadata you can specify your bean class as follows: + + + + + + + + + + + For details about the mechanism for supplying arguments to the constructor (if required) and + setting object instance properties after the object is constructed, see Injecting Dependencies. + + + + Instantiation with a Static Factory Method + + When defining a bean that you create with a static factory method, use the class attribute to specify + the class that contains the static factory method and an attribute named factory-method to specify + the name of the factory method itself. You should be able to call this method (with optional + arguments, as described later) and return a live object, which subsequently is treated as if it had + been created through a constructor. One use for such a bean definition is to call static factories in + legacy code. + + + The following bean definition specifies that the bean will be created by calling a factory method. + The definition does not specify the type (class) of the returned object, but rather the class + containing the factory method. In this example, the createInstance() method must be a static + method. The following example shows how to specify a factory method: + + + + + + The following example shows a class that would work with the preceding bean definition: + + + Java + + + public class ClientService { +   private static ClientService clientService = new ClientService(); +   private ClientService() {} + + +   public static ClientService createInstance() { +   return clientService; +   } + } + + + + Kotlin + + + class ClientService private constructor() { +   companion object { +   private val clientService = ClientService() +   @JvmStatic +   fun createInstance() = clientService +   } + } + + + + For details about the mechanism for supplying (optional) arguments to the factory method and + setting object instance properties after the object is returned from the factory, see Dependencies + and Configuration in Detail. + + + + Instantiation by Using an Instance Factory Method + + Similar to instantiation through a static factory method, instantiation with an instance factory + method invokes a non-static method of an existing bean from the container to create a new bean. + To use this mechanism, leave the class attribute empty and, in the factory-bean attribute, specify + the name of a bean in the current (or parent or ancestor) container that contains the instance + method that is to be invoked to create the object. Set the name of the factory method itself with the + factory-method attribute. The following example shows how to configure such a bean: + + + +   + + + + + + + + + The following example shows the corresponding class: + + + Java + + + public class DefaultServiceLocator { + + +   private static ClientService clientService = new ClientServiceImpl(); + + +   public ClientService createClientServiceInstance() { +   return clientService; +   } + } + + + + Kotlin + + + class DefaultServiceLocator { +   companion object { +   private val clientService = ClientServiceImpl() +   } +   fun createClientServiceInstance(): ClientService { +   return clientService +   } + } + + + + One factory class can also hold more than one factory method, as the following example shows: + + + + +   + + + + + + + + + The following example shows the corresponding class: + + + Java + + + public class DefaultServiceLocator { + + +   private static ClientService clientService = new ClientServiceImpl(); + + +   private static AccountService accountService = new AccountServiceImpl(); + + +   public ClientService createClientServiceInstance() { +   return clientService; +   } + + +   public AccountService createAccountServiceInstance() { +   return accountService; +   } + } + + + + Kotlin + + + class DefaultServiceLocator { +   companion object { +   private val clientService = ClientServiceImpl() +   private val accountService = AccountServiceImpl() +   } + + +   fun createClientServiceInstance(): ClientService { +   return clientService +   } + + +   fun createAccountServiceInstance(): AccountService { +   return accountService +   } + } + + + + This approach shows that the factory bean itself can be managed and configured through + dependency injection (DI). See Dependencies and Configuration in Detail. + + + In Spring documentation, "factory bean" refers to a bean that is configured in the + Spring container and that creates objects through an instance or static factory +  method. By contrast, FactoryBean (notice the capitalization) refers to a Spring- + specific FactoryBean implementation class. + + + + Determining a Bean’s Runtime Type + + The runtime type of a specific bean is non-trivial to determine. A specified class in the bean + metadata definition is just an initial class reference, potentially combined with a declared factory + method or being a FactoryBean class which may lead to a different runtime type of the bean, or not + + being set at all in case of an instance-level factory method (which is resolved via the specified + factory-bean name instead). Additionally, AOP proxying may wrap a bean instance with an + interface-based proxy with limited exposure of the target bean’s actual type (just its implemented + interfaces). + + The recommended way to find out about the actual runtime type of a particular bean is a + BeanFactory.getType call for the specified bean name. This takes all of the above cases into account + and returns the type of object that a BeanFactory.getBean call is going to return for the same bean + name. + + 2.1.4. Dependencies + + A typical enterprise application does not consist of a single object (or bean in the Spring parlance). + Even the simplest application has a few objects that work together to present what the end-user + sees as a coherent application. This next section explains how you go from defining a number of + bean definitions that stand alone to a fully realized application where objects collaborate to achieve + a goal. + + + Dependency Injection + + Dependency injection (DI) is a process whereby objects define their dependencies (that is, the other + objects with which they work) only through constructor arguments, arguments to a factory method, + or properties that are set on the object instance after it is constructed or returned from a factory + method. The container then injects those dependencies when it creates the bean. This process is + fundamentally the inverse (hence the name, Inversion of Control) of the bean itself controlling the + instantiation or location of its dependencies on its own by using direct construction of classes or the + Service Locator pattern. + + + Code is cleaner with the DI principle, and decoupling is more effective when objects are provided + with their dependencies. The object does not look up its dependencies and does not know the + location or class of the dependencies. As a result, your classes become easier to test, particularly + when the dependencies are on interfaces or abstract base classes, which allow for stub or mock + implementations to be used in unit tests. + + + DI exists in two major variants: Constructor-based dependency injection and Setter-based + dependency injection. + + + + Constructor-based Dependency Injection + + Constructor-based DI is accomplished by the container invoking a constructor with a number of + arguments, each representing a dependency. Calling a static factory method with specific + arguments to construct the bean is nearly equivalent, and this discussion treats arguments to a + constructor and to a static factory method similarly. The following example shows a class that can + only be dependency-injected with constructor injection: + + Java + + + public class SimpleMovieLister { + + +   // the SimpleMovieLister has a dependency on a MovieFinder +   private final MovieFinder movieFinder; + + +   // a constructor so that the Spring container can inject a MovieFinder +   public SimpleMovieLister(MovieFinder movieFinder) { +   this.movieFinder = movieFinder; +   } + + +   // business logic that actually uses the injected MovieFinder is omitted... + } + + + + Kotlin + + + // a constructor so that the Spring container can inject a MovieFinder + class SimpleMovieLister(private val movieFinder: MovieFinder) { +   // business logic that actually uses the injected MovieFinder is omitted... + } + + + + Notice that there is nothing special about this class. It is a POJO that has no dependencies on + container specific interfaces, base classes, or annotations. + + + Constructor Argument Resolution + + Constructor argument resolution matching occurs by using the argument’s type. If no potential + ambiguity exists in the constructor arguments of a bean definition, the order in which the + constructor arguments are defined in a bean definition is the order in which those arguments are + supplied to the appropriate constructor when the bean is being instantiated. Consider the following + class: + + + Java + + + package x.y; + + + public class ThingOne { + + +   public ThingOne(ThingTwo thingTwo, ThingThree thingThree) { +   // ... +   } + } + + Kotlin + + + package x.y + + + class ThingOne(thingTwo: ThingTwo, thingThree: ThingThree) + + + + Assuming that the ThingTwo and ThingThree classes are not related by inheritance, no potential + ambiguity exists. Thus, the following configuration works fine, and you do not need to specify the + constructor argument indexes or types explicitly in the element. + + + + +   +   +   +   + + +   + + +   + + + + + When another bean is referenced, the type is known, and matching can occur (as was the case with + the preceding example). When a simple type is used, such as true, Spring cannot + determine the type of the value, and so cannot match by type without help. Consider the following + class: + + + Java + + + package examples; + + + public class ExampleBean { + + +   // Number of years to calculate the Ultimate Answer +   private final int years; + + +   // The Answer to Life, the Universe, and Everything +   private final String ultimateAnswer; + + +   public ExampleBean(int years, String ultimateAnswer) { +   this.years = years; +   this.ultimateAnswer = ultimateAnswer; +   } + } + + Kotlin + + + package examples + + + class ExampleBean( +   private val years: Int, // Number of years to calculate the Ultimate Answer +   private val ultimateAnswer: String // The Answer to Life, the Universe, and + Everything + ) + + + + Constructor argument type matching + In the preceding scenario, the container can use type matching with simple types if you explicitly + specify the type of the constructor argument by using the type attribute, as the following example + shows: + + + + +   +   + + + + + Constructor argument index + You can use the index attribute to specify explicitly the index of constructor arguments, as the + following example shows: + + + + +   +   + + + + + In addition to resolving the ambiguity of multiple simple values, specifying an index resolves + ambiguity where a constructor has two arguments of the same type. + +  The index is 0-based. + + + Constructor argument name + You can also use the constructor parameter name for value disambiguation, as the following + example shows: + + + + +   +   + + + + + Keep in mind that, to make this work out of the box, your code must be compiled with the debug + flag enabled so that Spring can look up the parameter name from the constructor. If you cannot or + + do not want to compile your code with the debug flag, you can use the @ConstructorProperties JDK + annotation to explicitly name your constructor arguments. The sample class would then have to + look as follows: + + + Java + + + package examples; + + + public class ExampleBean { + + +   // Fields omitted + + +   @ConstructorProperties({"years", "ultimateAnswer"}) +   public ExampleBean(int years, String ultimateAnswer) { +   this.years = years; +   this.ultimateAnswer = ultimateAnswer; +   } + } + + + + Kotlin + + + package examples + + + class ExampleBean + @ConstructorProperties("years", "ultimateAnswer") + constructor(val years: Int, val ultimateAnswer: String) + + + + + Setter-based Dependency Injection + + Setter-based DI is accomplished by the container calling setter methods on your beans after + invoking a no-argument constructor or a no-argument static factory method to instantiate your + bean. + + The following example shows a class that can only be dependency-injected by using pure setter + injection. This class is conventional Java. It is a POJO that has no dependencies on container specific + interfaces, base classes, or annotations. + + Java + + + public class SimpleMovieLister { + + +   // the SimpleMovieLister has a dependency on the MovieFinder +   private MovieFinder movieFinder; + + +   // a setter method so that the Spring container can inject a MovieFinder +   public void setMovieFinder(MovieFinder movieFinder) { +   this.movieFinder = movieFinder; +   } + + +   // business logic that actually uses the injected MovieFinder is omitted... + } + + + + Kotlin + + + class SimpleMovieLister { + + +   // a late-initialized property so that the Spring container can inject a + MovieFinder +   lateinit var movieFinder: MovieFinder + + +   // business logic that actually uses the injected MovieFinder is omitted... + } + + + + The ApplicationContext supports constructor-based and setter-based DI for the beans it manages. It + also supports setter-based DI after some dependencies have already been injected through the + constructor approach. You configure the dependencies in the form of a BeanDefinition, which you + use in conjunction with PropertyEditor instances to convert properties from one format to another. + However, most Spring users do not work with these classes directly (that is, programmatically) but + rather with XML bean definitions, annotated components (that is, classes annotated with @Component, + @Controller, and so forth), or @Bean methods in Java-based @Configuration classes. These sources are + then converted internally into instances of BeanDefinition and used to load an entire Spring IoC + container instance. + + Constructor-based or setter-based DI? + + Since you can mix constructor-based and setter-based DI, it is a good rule of thumb to use + constructors for mandatory dependencies and setter methods or configuration methods for + optional dependencies. Note that use of the @Autowired annotation on a setter method can + be used to make the property be a required dependency; however, constructor injection with + programmatic validation of arguments is preferable. + + The Spring team generally advocates constructor injection, as it lets you implement + application components as immutable objects and ensures that required dependencies are + not null. Furthermore, constructor-injected components are always returned to the client + (calling) code in a fully initialized state. As a side note, a large number of constructor + arguments is a bad code smell, implying that the class likely has too many responsibilities and + should be refactored to better address proper separation of concerns. + + + Setter injection should primarily only be used for optional dependencies that can be assigned + reasonable default values within the class. Otherwise, not-null checks must be performed + everywhere the code uses the dependency. One benefit of setter injection is that setter + methods make objects of that class amenable to reconfiguration or re-injection later. + Management through JMX MBeans is therefore a compelling use case for setter injection. + + + Use the DI style that makes the most sense for a particular class. Sometimes, when dealing + with third-party classes for which you do not have the source, the choice is made for you. For + example, if a third-party class does not expose any setter methods, then constructor injection + may be the only available form of DI. + + + + + + Dependency Resolution Process + + The container performs bean dependency resolution as follows: + + + • The ApplicationContext is created and initialized with configuration metadata that describes all + the beans. Configuration metadata can be specified by XML, Java code, or annotations. + + • For each bean, its dependencies are expressed in the form of properties, constructor arguments, + or arguments to the static-factory method (if you use that instead of a normal constructor). + These dependencies are provided to the bean, when the bean is actually created. + + • Each property or constructor argument is an actual definition of the value to set, or a reference + to another bean in the container. + + • Each property or constructor argument that is a value is converted from its specified format to + the actual type of that property or constructor argument. By default, Spring can convert a value + supplied in string format to all built-in types, such as int, long, String, boolean, and so forth. + + The Spring container validates the configuration of each bean as the container is created. However, + the bean properties themselves are not set until the bean is actually created. Beans that are + singleton-scoped and set to be pre-instantiated (the default) are created when the container is + created. Scopes are defined in Bean Scopes. Otherwise, the bean is created only when it is + requested. Creation of a bean potentially causes a graph of beans to be created, as the bean’s + dependencies and its dependencies' dependencies (and so on) are created and assigned. Note that + + resolution mismatches among those dependencies may show up late — that is, on first creation of + the affected bean. + + + Circular dependencies + + If you use predominantly constructor injection, it is possible to create an unresolvable + circular dependency scenario. + + + For example: Class A requires an instance of class B through constructor injection, and class B + requires an instance of class A through constructor injection. If you configure beans for + classes A and B to be injected into each other, the Spring IoC container detects this circular + reference at runtime, and throws a BeanCurrentlyInCreationException. + + + One possible solution is to edit the source code of some classes to be configured by setters + rather than constructors. Alternatively, avoid constructor injection and use setter injection + only. In other words, although it is not recommended, you can configure circular + dependencies with setter injection. + + + Unlike the typical case (with no circular dependencies), a circular dependency between bean + A and bean B forces one of the beans to be injected into the other prior to being fully + initialized itself (a classic chicken-and-egg scenario). + + + + You can generally trust Spring to do the right thing. It detects configuration problems, such as + references to non-existent beans and circular dependencies, at container load-time. Spring sets + properties and resolves dependencies as late as possible, when the bean is actually created. This + means that a Spring container that has loaded correctly can later generate an exception when you + request an object if there is a problem creating that object or one of its dependencies — for + example, the bean throws an exception as a result of a missing or invalid property. This potentially + delayed visibility of some configuration issues is why ApplicationContext implementations by + default pre-instantiate singleton beans. At the cost of some upfront time and memory to create + these beans before they are actually needed, you discover configuration issues when the + ApplicationContext is created, not later. You can still override this default behavior so that singleton + beans initialize lazily, rather than being eagerly pre-instantiated. + + + If no circular dependencies exist, when one or more collaborating beans are being injected into a + dependent bean, each collaborating bean is totally configured prior to being injected into the + dependent bean. This means that, if bean A has a dependency on bean B, the Spring IoC container + completely configures bean B prior to invoking the setter method on bean A. In other words, the + bean is instantiated (if it is not a pre-instantiated singleton), its dependencies are set, and the + relevant lifecycle methods (such as a configured init method or the InitializingBean callback + method) are invoked. + + + + Examples of Dependency Injection + + The following example uses XML-based configuration metadata for setter-based DI. A small part of + a Spring XML configuration file specifies some bean definitions as follows: + + +   +   +   +   + + +   +   +   + + + + + + + + + The following example shows the corresponding ExampleBean class: + + + Java + + + public class ExampleBean { + + +   private AnotherBean beanOne; + + +   private YetAnotherBean beanTwo; + + +   private int i; + + +   public void setBeanOne(AnotherBean beanOne) { +   this.beanOne = beanOne; +   } + + +   public void setBeanTwo(YetAnotherBean beanTwo) { +   this.beanTwo = beanTwo; +   } + + +   public void setIntegerProperty(int i) { +   this.i = i; +   } + } + + + + Kotlin + + + class ExampleBean { +   lateinit var beanOne: AnotherBean +   lateinit var beanTwo: YetAnotherBean +   var i: Int = 0 + } + + + + In the preceding example, setters are declared to match against the properties specified in the XML + + file. The following example uses constructor-based DI: + + + + +   +   +   +   + + +   +   + + +   + + + + + + + + + The following example shows the corresponding ExampleBean class: + + + Java + + + public class ExampleBean { + + +   private AnotherBean beanOne; + + +   private YetAnotherBean beanTwo; + + +   private int i; + + +   public ExampleBean( +   AnotherBean anotherBean, YetAnotherBean yetAnotherBean, int i) { +   this.beanOne = anotherBean; +   this.beanTwo = yetAnotherBean; +   this.i = i; +   } + } + + + + Kotlin + + + class ExampleBean( +   private val beanOne: AnotherBean, +   private val beanTwo: YetAnotherBean, +   private val i: Int) + + + + The constructor arguments specified in the bean definition are used as arguments to the + constructor of the ExampleBean. + + + Now consider a variant of this example, where, instead of using a constructor, Spring is told to call + a static factory method to return an instance of the object: + + +   +   +   + + + + + + + + + The following example shows the corresponding ExampleBean class: + + + Java + + + public class ExampleBean { + + +   // a private constructor +   private ExampleBean(...) { +   ... +   } + + +   // a static factory method; the arguments to this method can be +   // considered the dependencies of the bean that is returned, +   // regardless of how those arguments are actually used. +   public static ExampleBean createInstance ( +   AnotherBean anotherBean, YetAnotherBean yetAnotherBean, int i) { + + +   ExampleBean eb = new ExampleBean (...); +   // some other operations... +   return eb; +   } + } + + + + Kotlin + + + class ExampleBean private constructor() { +   companion object { +   // a static factory method; the arguments to this method can be +   // considered the dependencies of the bean that is returned, +   // regardless of how those arguments are actually used. +   @JvmStatic +   fun createInstance(anotherBean: AnotherBean, yetAnotherBean: YetAnotherBean, + i: Int): ExampleBean { +   val eb = ExampleBean (...) +   // some other operations... +   return eb +   } +   } + } + + Arguments to the static factory method are supplied by elements, exactly the + same as if a constructor had actually been used. The type of the class being returned by the factory + method does not have to be of the same type as the class that contains the static factory method + (although, in this example, it is). An instance (non-static) factory method can be used in an + essentially identical fashion (aside from the use of the factory-bean attribute instead of the class + attribute), so we do not discuss those details here. + + + Dependencies and Configuration in Detail + + As mentioned in the previous section, you can define bean properties and constructor arguments as + references to other managed beans (collaborators) or as values defined inline. Spring’s XML-based + configuration metadata supports sub-element types within its and + elements for this purpose. + + + + Straight Values (Primitives, Strings, and so on) + + The value attribute of the element specifies a property or constructor argument as a + human-readable string representation. Spring’s conversion service is used to convert these values + from a String to the actual type of the property or argument. The following example shows various + values being set: + + + + +   +   +   +   +   + + + + + The following example uses the p-namespace for even more succinct XML configuration: + + + + + + +   + + + + + + + The preceding XML is more succinct. However, typos are discovered at runtime rather than design + + time, unless you use an IDE (such as IntelliJ IDEA or the Spring Tools for Eclipse) that supports + automatic property completion when you create bean definitions. Such IDE assistance is highly + recommended. + + + You can also configure a java.util.Properties instance, as follows: + + + + + + +   +   +   +   jdbc.driver.className=com.mysql.jdbc.Driver +   jdbc.url=jdbc:mysql://localhost:3306/mydb +   +   + + + + + The Spring container converts the text inside the element into a java.util.Properties + instance by using the JavaBeans PropertyEditor mechanism. This is a nice shortcut, and is one of a + few places where the Spring team do favor the use of the nested element over the value + attribute style. + + + The idref element + + The idref element is simply an error-proof way to pass the id (a string value - not a reference) of + another bean in the container to a or element. The following + example shows how to use it: + + + + + + + +   +   +   + + + + + The preceding bean definition snippet is exactly equivalent (at runtime) to the following snippet: + + + + + + + +   + + + + + The first form is preferable to the second, because using the idref tag lets the container validate at + deployment time that the referenced, named bean actually exists. In the second variation, no + + validation is performed on the value that is passed to the targetName property of the client bean. + Typos are only discovered (with most likely fatal results) when the client bean is actually + instantiated. If the client bean is a prototype bean, this typo and the resulting exception may only + be discovered long after the container is deployed. + + + The local attribute on the idref element is no longer supported in the 4.0 beans + XSD, since it does not provide value over a regular bean reference any more. +  Change your existing idref local references to idref bean when upgrading to the + 4.0 schema. + + + A common place (at least in versions earlier than Spring 2.0) where the element brings + value is in the configuration of AOP interceptors in a ProxyFactoryBean bean definition. Using + elements when you specify the interceptor names prevents you from misspelling an + interceptor ID. + + + + References to Other Beans (Collaborators) + + The ref element is the final element inside a or definition element. + Here, you set the value of the specified property of a bean to be a reference to another bean (a + collaborator) managed by the container. The referenced bean is a dependency of the bean whose + property is to be set, and it is initialized on demand as needed before the property is set. (If the + collaborator is a singleton bean, it may already be initialized by the container.) All references are + ultimately a reference to another object. Scoping and validation depend on whether you specify the + ID or name of the other object through the bean or parent attribute. + + + Specifying the target bean through the bean attribute of the tag is the most general form and + allows creation of a reference to any bean in the same container or parent container, regardless of + whether it is in the same XML file. The value of the bean attribute may be the same as the id + attribute of the target bean or be the same as one of the values in the name attribute of the target + bean. The following example shows how to use a ref element: + + + + + + + + Specifying the target bean through the parent attribute creates a reference to a bean that is in a + parent container of the current container. The value of the parent attribute may be the same as + either the id attribute of the target bean or one of the values in the name attribute of the target bean. + The target bean must be in a parent container of the current one. You should use this bean + reference variant mainly when you have a hierarchy of containers and you want to wrap an + existing bean in a parent container with a proxy that has the same name as the parent bean. The + following pair of listings shows how to use the parent attribute: + + + + + +   + + + + +   class="org.springframework.aop.framework.ProxyFactoryBean"> +   +   +   +   + + + + + + The local attribute on the ref element is no longer supported in the 4.0 beans XSD, +  since it does not provide value over a regular bean reference any more. Change + your existing ref local references to ref bean when upgrading to the 4.0 schema. + + + + Inner Beans + + A element inside the or elements defines an inner bean, as + the following example shows: + + + + +   +   +   +   +   +   +   + + + + + An inner bean definition does not require a defined ID or name. If specified, the container does not + use such a value as an identifier. The container also ignores the scope flag on creation, because + inner beans are always anonymous and are always created with the outer bean. It is not possible to + access inner beans independently or to inject them into collaborating beans other than into the + enclosing bean. + + + As a corner case, it is possible to receive destruction callbacks from a custom scope — for example, + for a request-scoped inner bean contained within a singleton bean. The creation of the inner bean + instance is tied to its containing bean, but destruction callbacks let it participate in the request + scope’s lifecycle. This is not a common scenario. Inner beans typically simply share their containing + bean’s scope. + + + + Collections + + The , , , and elements set the properties and arguments of the Java + Collection types List, Set, Map, and Properties, respectively. The following example shows how to + use them: + + +   +   +   +   administrator@example.org +   support@example.org +   development@example.org +   +   +   +   +   +   a list element followed by a reference +   +   +   +   +   +   +   +   +   +   +   +   +   +   just some string +   +   +   + + + + + The value of a map key or value, or a set value, can also be any of the following elements: + + + + bean | ref | idref | list | set | map | props | value | null + + + + Collection Merging + + The Spring container also supports merging collections. An application developer can define a + parent , , or element and have child , , or + elements inherit and override values from the parent collection. That is, the child collection’s + values are the result of merging the elements of the parent and child collections, with the child’s + collection elements overriding values specified in the parent collection. + + + This section on merging discusses the parent-child bean mechanism. Readers unfamiliar with + parent and child bean definitions may wish to read the relevant section before continuing. + + + The following example demonstrates collection merging: + + +   +   +   +   administrator@example.com +   support@example.com +   +   +   +   +   +   +   +   sales@example.com +   support@example.co.uk +   +   +   + + + + + Notice the use of the merge=true attribute on the element of the adminEmails property of the + child bean definition. When the child bean is resolved and instantiated by the container, the + resulting instance has an adminEmails Properties collection that contains the result of merging the + child’s adminEmails collection with the parent’s adminEmails collection. The following listing shows + the result: + + + + administrator=administrator@example.com + sales=sales@example.com + support=support@example.co.uk + + + + The child Properties collection’s value set inherits all property elements from the parent , + and the child’s value for the support value overrides the value in the parent collection. + + + This merging behavior applies similarly to the , , and collection types. In the + specific case of the element, the semantics associated with the List collection type (that is, + the notion of an ordered collection of values) is maintained. The parent’s values precede all of the + child list’s values. In the case of the Map, Set, and Properties collection types, no ordering exists. + Hence, no ordering semantics are in effect for the collection types that underlie the associated Map, + Set, and Properties implementation types that the container uses internally. + + + Limitations of Collection Merging + + You cannot merge different collection types (such as a Map and a List). If you do attempt to do so, an + appropriate Exception is thrown. The merge attribute must be specified on the lower, inherited, child + definition. Specifying the merge attribute on a parent collection definition is redundant and does not + result in the desired merging. + + Strongly-typed collection + + Thanks to Java’s support for generic types, you can use strongly typed collections. That is, it is + possible to declare a Collection type such that it can only contain (for example) String elements. If + you use Spring to dependency-inject a strongly-typed Collection into a bean, you can take + advantage of Spring’s type-conversion support such that the elements of your strongly-typed + Collection instances are converted to the appropriate type prior to being added to the Collection. + The following Java class and bean definition show how to do so: + + + Java + + + public class SomeClass { + + +   private Map accounts; + + +   public void setAccounts(Map accounts) { +   this.accounts = accounts; +   } + } + + + + Kotlin + + + class SomeClass { +   lateinit var accounts: Map + } + + + + + +   +   +   +   +   +   +   +   +   + + + + + When the accounts property of the something bean is prepared for injection, the generics + information about the element type of the strongly-typed Map is available by + reflection. Thus, Spring’s type conversion infrastructure recognizes the various value elements as + being of type Float, and the string values (9.99, 2.75, and 3.99) are converted into an actual Float + type. + + + + Null and Empty String Values + + Spring treats empty arguments for properties and the like as empty Strings. The following XML- + based configuration metadata snippet sets the email property to the empty String value (""). + + +   + + + + + The preceding example is equivalent to the following Java code: + + + Java + + + exampleBean.setEmail(""); + + + + Kotlin + + + exampleBean.email = "" + + + + The element handles null values. The following listing shows an example: + + + + +   +   +   + + + + + The preceding configuration is equivalent to the following Java code: + + + Java + + + exampleBean.setEmail(null); + + + + Kotlin + + + exampleBean.email = null + + + + + XML Shortcut with the p-namespace + + The p-namespace lets you use the bean element’s attributes (instead of nested elements) + to describe your property values collaborating beans, or both. + + + Spring supports extensible configuration formats with namespaces, which are based on an XML + Schema definition. The beans configuration format discussed in this chapter is defined in an XML + Schema document. However, the p-namespace is not defined in an XSD file and exists only in the + core of Spring. + + + The following example shows two XML snippets (the first uses standard XML format and the + second uses the p-namespace) that resolve to the same result: + + + + +   +   +   + + +   + + + + + The example shows an attribute in the p-namespace called email in the bean definition. This tells + Spring to include a property declaration. As previously mentioned, the p-namespace does not have + a schema definition, so you can set the name of the attribute to the property name. + + + This next example includes two more bean definitions that both have a reference to another bean: + + + + + + +   +   +   +   + + +   + + +   +   +   + + + + + This example includes not only a property value using the p-namespace but also uses a special + format to declare property references. Whereas the first bean definition uses to create a reference from bean john to bean jane, the second bean + definition uses p:spouse-ref="jane" as an attribute to do the exact same thing. In this case, spouse is + the property name, whereas the -ref part indicates that this is not a straight value but rather a + reference to another bean. + + The p-namespace is not as flexible as the standard XML format. For example, the + format for declaring property references clashes with properties that end in Ref, +  whereas the standard XML format does not. We recommend that you choose your + approach carefully and communicate this to your team members to avoid + producing XML documents that use all three approaches at the same time. + + + + XML Shortcut with the c-namespace + + Similar to the XML Shortcut with the p-namespace, the c-namespace, introduced in Spring 3.1, + allows inlined attributes for configuring the constructor arguments rather then nested constructor- + arg elements. + + + The following example uses the c: namespace to do the same thing as the from Constructor-based + Dependency Injection: + + + + + + +   +   + + +   +   +   +   +   +   + + +   +   + + + + + + + The c: namespace uses the same conventions as the p: one (a trailing -ref for bean references) for + setting the constructor arguments by their names. Similarly, it needs to be declared in the XML file + even though it is not defined in an XSD schema (it exists inside the Spring core). + + For the rare cases where the constructor argument names are not available (usually if the bytecode + was compiled without debugging information), you can use fallback to the argument indexes, as + follows: + + + + + + + + Due to the XML grammar, the index notation requires the presence of the leading + _, as XML attribute names cannot start with a number (even though some IDEs +  allow it). A corresponding index notation is also available for + elements but not commonly used since the plain order of declaration is usually + sufficient there. + + + In practice, the constructor resolution mechanism is quite efficient in matching arguments, so + unless you really need to, we recommend using the name notation throughout your configuration. + + + + Compound Property Names + + You can use compound or nested property names when you set bean properties, as long as all + components of the path except the final property name are not null. Consider the following bean + definition: + + + + +   + + + + + The something bean has a fred property, which has a bob property, which has a sammy property, and + that final sammy property is being set to a value of 123. In order for this to work, the fred property of + something and the bob property of fred must not be null after the bean is constructed. Otherwise, a + NullPointerException is thrown. + + + Using depends-on + + If a bean is a dependency of another bean, that usually means that one bean is set as a property of + another. Typically you accomplish this with the element in XML-based configuration + metadata. However, sometimes dependencies between beans are less direct. An example is when a + static initializer in a class needs to be triggered, such as for database driver registration. The + depends-on attribute can explicitly force one or more beans to be initialized before the bean using + this element is initialized. The following example uses the depends-on attribute to express a + dependency on a single bean: + + + + + + + + + To express a dependency on multiple beans, supply a list of bean names as the value of the depends- + on attribute (commas, whitespace, and semicolons are valid delimiters): + + +   + + + + + + + + + + The depends-on attribute can specify both an initialization-time dependency and, in + the case of singleton beans only, a corresponding destruction-time dependency. +  Dependent beans that define a depends-on relationship with a given bean are + destroyed first, prior to the given bean itself being destroyed. Thus, depends-on can + also control shutdown order. + + + + Lazy-initialized Beans + + By default, ApplicationContext implementations eagerly create and configure all singleton beans as + part of the initialization process. Generally, this pre-instantiation is desirable, because errors in the + configuration or surrounding environment are discovered immediately, as opposed to hours or + even days later. When this behavior is not desirable, you can prevent pre-instantiation of a + singleton bean by marking the bean definition as being lazy-initialized. A lazy-initialized bean tells + the IoC container to create a bean instance when it is first requested, rather than at startup. + + + In XML, this behavior is controlled by the lazy-init attribute on the element, as the + following example shows: + + + + + + + + + When the preceding configuration is consumed by an ApplicationContext, the lazy bean is not + eagerly pre-instantiated when the ApplicationContext starts, whereas the not.lazy bean is eagerly + pre-instantiated. + + + However, when a lazy-initialized bean is a dependency of a singleton bean that is not lazy- + initialized, the ApplicationContext creates the lazy-initialized bean at startup, because it must + satisfy the singleton’s dependencies. The lazy-initialized bean is injected into a singleton bean + elsewhere that is not lazy-initialized. + + You can also control lazy-initialization at the container level by using the default-lazy-init + attribute on the element, as the following example shows: + + + + +   + + + Autowiring Collaborators + + The Spring container can autowire relationships between collaborating beans. You can let Spring + resolve collaborators (other beans) automatically for your bean by inspecting the contents of the + ApplicationContext. Autowiring has the following advantages: + + • Autowiring can significantly reduce the need to specify properties or constructor arguments. + (Other mechanisms such as a bean template discussed elsewhere in this chapter are also + valuable in this regard.) + + • Autowiring can update a configuration as your objects evolve. For example, if you need to add a + dependency to a class, that dependency can be satisfied automatically without you needing to + modify the configuration. Thus autowiring can be especially useful during development, + without negating the option of switching to explicit wiring when the code base becomes more + stable. + + + When using XML-based configuration metadata (see Dependency Injection), you can specify the + autowire mode for a bean definition with the autowire attribute of the element. The + autowiring functionality has four modes. You specify autowiring per bean and can thus choose + which ones to autowire. The following table describes the four autowiring modes: + + + Table 2. Autowiring modes + + Mode Explanation + no (Default) No autowiring. Bean references must be defined by ref elements. + Changing the default setting is not recommended for larger deployments, + because specifying collaborators explicitly gives greater control and clarity. To + some extent, it documents the structure of a system. + byName Autowiring by property name. Spring looks for a bean with the same name as + the property that needs to be autowired. For example, if a bean definition is + set to autowire by name and it contains a master property (that is, it has a + setMaster(..) method), Spring looks for a bean definition named master and + uses it to set the property. + byType Lets a property be autowired if exactly one bean of the property type exists in + the container. If more than one exists, a fatal exception is thrown, which + indicates that you may not use byType autowiring for that bean. If there are no + matching beans, nothing happens (the property is not set). + constructor Analogous to byType but applies to constructor arguments. If there is not + exactly one bean of the constructor argument type in the container, a fatal + error is raised. + + + With byType or constructor autowiring mode, you can wire arrays and typed collections. In such + cases, all autowire candidates within the container that match the expected type are provided to + satisfy the dependency. You can autowire strongly-typed Map instances if the expected key type is + String. An autowired Map instance’s values consist of all bean instances that match the expected + type, and the Map instance’s keys contain the corresponding bean names. + + Limitations and Disadvantages of Autowiring + + Autowiring works best when it is used consistently across a project. If autowiring is not used in + general, it might be confusing to developers to use it to wire only one or two bean definitions. + + + Consider the limitations and disadvantages of autowiring: + + • Explicit dependencies in property and constructor-arg settings always override autowiring. You + cannot autowire simple properties such as primitives, Strings, and Classes (and arrays of such + simple properties). This limitation is by-design. + + • Autowiring is less exact than explicit wiring. Although, as noted in the earlier table, Spring is + careful to avoid guessing in case of ambiguity that might have unexpected results. The + relationships between your Spring-managed objects are no longer documented explicitly. + + • Wiring information may not be available to tools that may generate documentation from a + Spring container. + + • Multiple bean definitions within the container may match the type specified by the setter + method or constructor argument to be autowired. For arrays, collections, or Map instances, this is + not necessarily a problem. However, for dependencies that expect a single value, this ambiguity + is not arbitrarily resolved. If no unique bean definition is available, an exception is thrown. + + + In the latter scenario, you have several options: + + • Abandon autowiring in favor of explicit wiring. + + • Avoid autowiring for a bean definition by setting its autowire-candidate attributes to false, as + described in the next section. + + • Designate a single bean definition as the primary candidate by setting the primary attribute of its + element to true. + + • Implement the more fine-grained control available with annotation-based configuration, as + described in Annotation-based Container Configuration. + + + + Excluding a Bean from Autowiring + + On a per-bean basis, you can exclude a bean from autowiring. In Spring’s XML format, set the + autowire-candidate attribute of the element to false. The container makes that specific bean + definition unavailable to the autowiring infrastructure (including annotation style configurations + such as @Autowired). + + + The autowire-candidate attribute is designed to only affect type-based autowiring. + It does not affect explicit references by name, which get resolved even if the +  specified bean is not marked as an autowire candidate. As a consequence, + autowiring by name nevertheless injects a bean if the name matches. + + + You can also limit autowire candidates based on pattern-matching against bean names. The top- + level element accepts one or more patterns within its default-autowire-candidates + attribute. For example, to limit autowire candidate status to any bean whose name ends with + Repository, provide a value of *Repository. To provide multiple patterns, define them in a comma- + separated list. An explicit value of true or false for a bean definition’s autowire-candidate attribute + + always takes precedence. For such beans, the pattern matching rules do not apply. + + + These techniques are useful for beans that you never want to be injected into other beans by + autowiring. It does not mean that an excluded bean cannot itself be configured by using + autowiring. Rather, the bean itself is not a candidate for autowiring other beans. + + + Method Injection + + In most application scenarios, most beans in the container are singletons. When a singleton bean + needs to collaborate with another singleton bean or a non-singleton bean needs to collaborate with + another non-singleton bean, you typically handle the dependency by defining one bean as a + property of the other. A problem arises when the bean lifecycles are different. Suppose singleton + bean A needs to use non-singleton (prototype) bean B, perhaps on each method invocation on A. + The container creates the singleton bean A only once, and thus only gets one opportunity to set the + properties. The container cannot provide bean A with a new instance of bean B every time one is + needed. + + A solution is to forego some inversion of control. You can make bean A aware of the container by + implementing the ApplicationContextAware interface, and by making a getBean("B") call to the + container ask for (a typically new) bean B instance every time bean A needs it. The following + example shows this approach: + + Java + + + // a class that uses a stateful Command-style class to perform some processing + package fiona.apple; + + + // Spring-API imports + import org.springframework.beans.BeansException; + import org.springframework.context.ApplicationContext; + import org.springframework.context.ApplicationContextAware; + + + public class CommandManager implements ApplicationContextAware { + + +   private ApplicationContext applicationContext; + + +   public Object process(Map commandState) { +   // grab a new instance of the appropriate Command +   Command command = createCommand(); +   // set the state on the (hopefully brand new) Command instance +   command.setState(commandState); +   return command.execute(); +   } + + +   protected Command createCommand() { +   // notice the Spring API dependency! +   return this.applicationContext.getBean("command", Command.class); +   } + + +   public void setApplicationContext( +   ApplicationContext applicationContext) throws BeansException { +   this.applicationContext = applicationContext; +   } + } + + Kotlin + + + // a class that uses a stateful Command-style class to perform some processing + package fiona.apple + + + // Spring-API imports + import org.springframework.context.ApplicationContext + import org.springframework.context.ApplicationContextAware + + + class CommandManager : ApplicationContextAware { + + +   private lateinit var applicationContext: ApplicationContext + + +   fun process(commandState: Map<*, *>): Any { +   // grab a new instance of the appropriate Command +   val command = createCommand() +   // set the state on the (hopefully brand new) Command instance +   command.state = commandState +   return command.execute() +   } + + +   // notice the Spring API dependency! +   protected fun createCommand() = +   applicationContext.getBean("command", Command::class.java) + + +   override fun setApplicationContext(applicationContext: ApplicationContext) { +   this.applicationContext = applicationContext +   } + } + + + + The preceding is not desirable, because the business code is aware of and coupled to the Spring + Framework. Method Injection, a somewhat advanced feature of the Spring IoC container, lets you + handle this use case cleanly. + + + + You can read more about the motivation for Method Injection in this blog entry. + + + + + + Lookup Method Injection + + Lookup method injection is the ability of the container to override methods on container-managed + beans and return the lookup result for another named bean in the container. The lookup typically + involves a prototype bean, as in the scenario described in the preceding section. The Spring + Framework implements this method injection by using bytecode generation from the CGLIB library + to dynamically generate a subclass that overrides the method. + + • For this dynamic subclassing to work, the class that the Spring bean container + subclasses cannot be final, and the method to be overridden cannot be final, + either. + + • Unit-testing a class that has an abstract method requires you to subclass the + class yourself and to supply a stub implementation of the abstract method. +  • Concrete methods are also necessary for component scanning, which requires + concrete classes to pick up. + + • A further key limitation is that lookup methods do not work with factory + methods and in particular not with @Bean methods in configuration classes, + since, in that case, the container is not in charge of creating the instance and + therefore cannot create a runtime-generated subclass on the fly. + + + In the case of the CommandManager class in the previous code snippet, the Spring container + dynamically overrides the implementation of the createCommand() method. The CommandManager class + does not have any Spring dependencies, as the reworked example shows: + + + Java + + + package fiona.apple; + + + // no more Spring imports! + + + public abstract class CommandManager { + + +   public Object process(Object commandState) { +   // grab a new instance of the appropriate Command interface +   Command command = createCommand(); +   // set the state on the (hopefully brand new) Command instance +   command.setState(commandState); +   return command.execute(); +   } + + +   // okay... but where is the implementation of this method? +   protected abstract Command createCommand(); + } + + Kotlin + + + package fiona.apple + + + // no more Spring imports! + + + abstract class CommandManager { + + +   fun process(commandState: Any): Any { +   // grab a new instance of the appropriate Command interface +   val command = createCommand() +   // set the state on the (hopefully brand new) Command instance +   command.state = commandState +   return command.execute() +   } + + +   // okay... but where is the implementation of this method? +   protected abstract fun createCommand(): Command + } + + + + In the client class that contains the method to be injected (the CommandManager in this case), the + method to be injected requires a signature of the following form: + + + + [abstract] theMethodName(no-arguments); + + + + If the method is abstract, the dynamically-generated subclass implements the method. Otherwise, + the dynamically-generated subclass overrides the concrete method defined in the original class. + Consider the following example: + + + + + +   + + + + + +   + + + + + The bean identified as commandManager calls its own createCommand() method whenever it needs a + new instance of the myCommand bean. You must be careful to deploy the myCommand bean as a prototype + if that is actually what is needed. If it is a singleton, the same instance of the myCommand bean is + returned each time. + + + Alternatively, within the annotation-based component model, you can declare a lookup method + through the @Lookup annotation, as the following example shows: + + Java + + + public abstract class CommandManager { + + +   public Object process(Object commandState) { +   Command command = createCommand(); +   command.setState(commandState); +   return command.execute(); +   } + + +   @Lookup("myCommand") +   protected abstract Command createCommand(); + } + + + + Kotlin + + + abstract class CommandManager { + + +   fun process(commandState: Any): Any { +   val command = createCommand() +   command.state = commandState +   return command.execute() +   } + + +   @Lookup("myCommand") +   protected abstract fun createCommand(): Command + } + + + + Or, more idiomatically, you can rely on the target bean getting resolved against the declared return + type of the lookup method: + + + Java + + + public abstract class CommandManager { + + +   public Object process(Object commandState) { +   Command command = createCommand(); +   command.setState(commandState); +   return command.execute(); +   } + + +   @Lookup +   protected abstract Command createCommand(); + } + + Kotlin + + + abstract class CommandManager { + + +   fun process(commandState: Any): Any { +   val command = createCommand() +   command.state = commandState +   return command.execute() +   } + + +   @Lookup +   protected abstract fun createCommand(): Command + } + + + + Note that you should typically declare such annotated lookup methods with a concrete stub + implementation, in order for them to be compatible with Spring’s component scanning rules where + abstract classes get ignored by default. This limitation does not apply to explicitly registered or + explicitly imported bean classes. + + + Another way of accessing differently scoped target beans is an ObjectFactory/ + Provider injection point. See Scoped Beans as Dependencies. +  + You may also find the ServiceLocatorFactoryBean (in the + org.springframework.beans.factory.config package) to be useful. + + + + Arbitrary Method Replacement + + A less useful form of method injection than lookup method injection is the ability to replace + arbitrary methods in a managed bean with another method implementation. You can safely skip + the rest of this section until you actually need this functionality. + + + With XML-based configuration metadata, you can use the replaced-method element to replace an + existing method implementation with another, for a deployed bean. Consider the following class, + which has a method called computeValue that we want to override: + + + Java + + + public class MyValueCalculator { + + +   public String computeValue(String input) { +   // some real code... +   } + + +   // some other methods... + } + + Kotlin + + + class MyValueCalculator { + + +   fun computeValue(input: String): String { +   // some real code... +   } + + +   // some other methods... + } + + + + A class that implements the org.springframework.beans.factory.support.MethodReplacer interface + provides the new method definition, as the following example shows: + + + Java + + + /** +  * meant to be used to override the existing computeValue(String) +  * implementation in MyValueCalculator +  */ + public class ReplacementComputeValue implements MethodReplacer { + + +   public Object reimplement(Object o, Method m, Object[] args) throws Throwable { +   // get the input value, work with it, and return a computed result +   String input = (String) args[0]; +   ... +   return ...; +   } + } + + + + Kotlin + + + /** +  * meant to be used to override the existing computeValue(String) +  * implementation in MyValueCalculator +  */ + class ReplacementComputeValue : MethodReplacer { + + +   override fun reimplement(obj: Any, method: Method, args: Array): Any { +   // get the input value, work with it, and return a computed result +   val input = args[0] as String; +   ... +   return ...; +   } + } + + + + The bean definition to deploy the original class and specify the method override would resemble + the following example: + + +   +   +   String +   + + + + + + + + You can use one or more elements within the element to indicate + the method signature of the method being overridden. The signature for the arguments is + necessary only if the method is overloaded and multiple variants exist within the class. For + convenience, the type string for an argument may be a substring of the fully qualified type name. + For example, the following all match java.lang.String: + + + + java.lang.String + String + Str + + + + Because the number of arguments is often enough to distinguish between each possible choice, this + shortcut can save a lot of typing, by letting you type only the shortest string that matches an + argument type. + + 2.1.5. Bean Scopes + + When you create a bean definition, you create a recipe for creating actual instances of the class + defined by that bean definition. The idea that a bean definition is a recipe is important, because it + means that, as with a class, you can create many object instances from a single recipe. + + + You can control not only the various dependencies and configuration values that are to be plugged + into an object that is created from a particular bean definition but also control the scope of the + objects created from a particular bean definition. This approach is powerful and flexible, because + you can choose the scope of the objects you create through configuration instead of having to bake + in the scope of an object at the Java class level. Beans can be defined to be deployed in one of a + number of scopes. The Spring Framework supports six scopes, four of which are available only if + you use a web-aware ApplicationContext. You can also create a custom scope. + + The following table describes the supported scopes: + + + Table 3. Bean scopes + + Scope Description + + singleton (Default) Scopes a single bean definition to a single object instance for each + Spring IoC container. + + prototype Scopes a single bean definition to any number of object instances. + + Scope Description + + request Scopes a single bean definition to the lifecycle of a single HTTP request. That + is, each HTTP request has its own instance of a bean created off the back of a + single bean definition. Only valid in the context of a web-aware Spring + ApplicationContext. + + session Scopes a single bean definition to the lifecycle of an HTTP Session. Only valid + in the context of a web-aware Spring ApplicationContext. + + application Scopes a single bean definition to the lifecycle of a ServletContext. Only valid + in the context of a web-aware Spring ApplicationContext. + + websocket Scopes a single bean definition to the lifecycle of a WebSocket. Only valid in the + context of a web-aware Spring ApplicationContext. + + + + As of Spring 3.0, a thread scope is available but is not registered by default. For +  more information, see the documentation for SimpleThreadScope. For instructions + on how to register this or any other custom scope, see Using a Custom Scope. + + + + The Singleton Scope + + Only one shared instance of a singleton bean is managed, and all requests for beans with an ID or + IDs that match that bean definition result in that one specific bean instance being returned by the + Spring container. + + + To put it another way, when you define a bean definition and it is scoped as a singleton, the Spring + IoC container creates exactly one instance of the object defined by that bean definition. This single + instance is stored in a cache of such singleton beans, and all subsequent requests and references + for that named bean return the cached object. The following image shows how the singleton scope + works: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Spring’s concept of a singleton bean differs from the singleton pattern as defined in the Gang of + Four (GoF) patterns book. The GoF singleton hard-codes the scope of an object such that one and + + only one instance of a particular class is created per ClassLoader. The scope of the Spring singleton + is best described as being per-container and per-bean. This means that, if you define one bean for a + particular class in a single Spring container, the Spring container creates one and only one instance + of the class defined by that bean definition. The singleton scope is the default scope in Spring. To + define a bean as a singleton in XML, you can define a bean as shown in the following example: + + + + + + + + + + + + + The Prototype Scope + + The non-singleton prototype scope of bean deployment results in the creation of a new bean + instance every time a request for that specific bean is made. That is, the bean is injected into + another bean or you request it through a getBean() method call on the container. As a rule, you + should use the prototype scope for all stateful beans and the singleton scope for stateless beans. + + + The following diagram illustrates the Spring prototype scope: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + (A data access object (DAO) is not typically configured as a prototype, because a typical DAO does + not hold any conversational state. It was easier for us to reuse the core of the singleton diagram.) + + The following example defines a bean as a prototype in XML: + + + + + + + + In contrast to the other scopes, Spring does not manage the complete lifecycle of a prototype bean. + + The container instantiates, configures, and otherwise assembles a prototype object and hands it to + the client, with no further record of that prototype instance. Thus, although initialization lifecycle + callback methods are called on all objects regardless of scope, in the case of prototypes, configured + destruction lifecycle callbacks are not called. The client code must clean up prototype-scoped + objects and release expensive resources that the prototype beans hold. To get the Spring container + to release resources held by prototype-scoped beans, try using a custom bean post-processor, which + holds a reference to beans that need to be cleaned up. + + + In some respects, the Spring container’s role in regard to a prototype-scoped bean is a replacement + for the Java new operator. All lifecycle management past that point must be handled by the client. + (For details on the lifecycle of a bean in the Spring container, see Lifecycle Callbacks.) + + + Singleton Beans with Prototype-bean Dependencies + + When you use singleton-scoped beans with dependencies on prototype beans, be aware that + dependencies are resolved at instantiation time. Thus, if you dependency-inject a prototype-scoped + bean into a singleton-scoped bean, a new prototype bean is instantiated and then dependency- + injected into the singleton bean. The prototype instance is the sole instance that is ever supplied to + the singleton-scoped bean. + + + However, suppose you want the singleton-scoped bean to acquire a new instance of the prototype- + scoped bean repeatedly at runtime. You cannot dependency-inject a prototype-scoped bean into + your singleton bean, because that injection occurs only once, when the Spring container + instantiates the singleton bean and resolves and injects its dependencies. If you need a new + instance of a prototype bean at runtime more than once, see Method Injection. + + + Request, Session, Application, and WebSocket Scopes + + The request, session, application, and websocket scopes are available only if you use a web-aware + Spring ApplicationContext implementation (such as XmlWebApplicationContext). If you use these + scopes with regular Spring IoC containers, such as the ClassPathXmlApplicationContext, an + IllegalStateException that complains about an unknown bean scope is thrown. + + + + Initial Web Configuration + + To support the scoping of beans at the request, session, application, and websocket levels (web- + scoped beans), some minor initial configuration is required before you define your beans. (This + initial setup is not required for the standard scopes: singleton and prototype.) + + + How you accomplish this initial setup depends on your particular Servlet environment. + + + If you access scoped beans within Spring Web MVC, in effect, within a request that is processed by + the Spring DispatcherServlet, no special setup is necessary. DispatcherServlet already exposes all + relevant state. + + + If you use a Servlet web container, with requests processed outside of Spring’s DispatcherServlet + (for example, when using JSF or Struts), you need to register the + org.springframework.web.context.request.RequestContextListener ServletRequestListener. This can + be done programmatically by using the WebApplicationInitializer interface. Alternatively, add the + following declaration to your web application’s web.xml file: + + +   ... +   +   +   org.springframework.web.context.request.RequestContextListener +   +   +   ... + + + + + Alternatively, if there are issues with your listener setup, consider using Spring’s + RequestContextFilter. The filter mapping depends on the surrounding web application + configuration, so you have to change it as appropriate. The following listing shows the filter part of + a web application: + + + + +   ... +   +   requestContextFilter +   org.springframework.web.filter.RequestContextFilter +   +   +   requestContextFilter +   /* +   +   ... + + + + + DispatcherServlet, RequestContextListener, and RequestContextFilter all do exactly the same thing, + namely bind the HTTP request object to the Thread that is servicing that request. This makes beans + that are request- and session-scoped available further down the call chain. + + + + Request scope + + Consider the following XML configuration for a bean definition: + + + + + + + + The Spring container creates a new instance of the LoginAction bean by using the loginAction bean + definition for each and every HTTP request. That is, the loginAction bean is scoped at the HTTP + request level. You can change the internal state of the instance that is created as much as you want, + because other instances created from the same loginAction bean definition do not see these + changes in state. They are particular to an individual request. When the request completes + processing, the bean that is scoped to the request is discarded. + + + When using annotation-driven components or Java configuration, the @RequestScope annotation can + + be used to assign a component to the request scope. The following example shows how to do so: + + + Java + + + @RequestScope + @Component + public class LoginAction { +   // ... + } + + + + Kotlin + + + @RequestScope + @Component + class LoginAction { +   // ... + } + + + + + Session Scope + + Consider the following XML configuration for a bean definition: + + + + + + + + The Spring container creates a new instance of the UserPreferences bean by using the + userPreferences bean definition for the lifetime of a single HTTP Session. In other words, the + userPreferences bean is effectively scoped at the HTTP Session level. As with request-scoped beans, + you can change the internal state of the instance that is created as much as you want, knowing that + other HTTP Session instances that are also using instances created from the same userPreferences + bean definition do not see these changes in state, because they are particular to an individual HTTP + Session. When the HTTP Session is eventually discarded, the bean that is scoped to that particular + HTTP Session is also discarded. + + + When using annotation-driven components or Java configuration, you can use the @SessionScope + annotation to assign a component to the session scope. + + + Java + + + @SessionScope + @Component + public class UserPreferences { +   // ... + } + + Kotlin + + + @SessionScope + @Component + class UserPreferences { +   // ... + } + + + + + Application Scope + + Consider the following XML configuration for a bean definition: + + + + + + + + The Spring container creates a new instance of the AppPreferences bean by using the appPreferences + bean definition once for the entire web application. That is, the appPreferences bean is scoped at the + ServletContext level and stored as a regular ServletContext attribute. This is somewhat similar to a + Spring singleton bean but differs in two important ways: It is a singleton per ServletContext, not per + Spring ApplicationContext (for which there may be several in any given web application), and it is + actually exposed and therefore visible as a ServletContext attribute. + + + When using annotation-driven components or Java configuration, you can use the + @ApplicationScope annotation to assign a component to the application scope. The following + example shows how to do so: + + + Java + + + @ApplicationScope + @Component + public class AppPreferences { +   // ... + } + + + + Kotlin + + + @ApplicationScope + @Component + class AppPreferences { +   // ... + } + + + + + WebSocket Scope + + WebSocket scope is associated with the lifecycle of a WebSocket session and applies to STOMP over + WebSocket applications, see WebSocket scope for more details. + + Scoped Beans as Dependencies + + The Spring IoC container manages not only the instantiation of your objects (beans), but also the + wiring up of collaborators (or dependencies). If you want to inject (for example) an HTTP request- + scoped bean into another bean of a longer-lived scope, you may choose to inject an AOP proxy in + place of the scoped bean. That is, you need to inject a proxy object that exposes the same public + interface as the scoped object but that can also retrieve the real target object from the relevant + scope (such as an HTTP request) and delegate method calls onto the real object. + + + You may also use between beans that are scoped as singleton, + with the reference then going through an intermediate proxy that is serializable + and therefore able to re-obtain the target singleton bean on deserialization. + + + When declaring against a bean of scope prototype, every + method call on the shared proxy leads to the creation of a new target instance to + which the call is then being forwarded. + + Also, scoped proxies are not the only way to access beans from shorter scopes in a + lifecycle-safe fashion. You may also declare your injection point (that is, the +  constructor or setter argument or autowired field) as ObjectFactory, + allowing for a getObject() call to retrieve the current instance on demand every + time it is needed — without holding on to the instance or storing it separately. + + + As an extended variant, you may declare ObjectProvider which + delivers several additional access variants, including getIfAvailable and + getIfUnique. + + + The JSR-330 variant of this is called Provider and is used with a + Provider declaration and a corresponding get() call for every + retrieval attempt. See here for more details on JSR-330 overall. + + + The configuration in the following example is only one line, but it is important to understand the + “why” as well as the “how” behind it: + + + + + +   +   +   +   ① +   + + +   +   +   +   +   + + + + ① The line that defines the proxy. + + To create such a proxy, you insert a child element into a scoped bean definition + (see Choosing the Type of Proxy to Create and XML Schema-based configuration). Why do + definitions of beans scoped at the request, session and custom-scope levels require the element? Consider the following singleton bean definition and contrast it with what you + need to define for the aforementioned scopes (note that the following userPreferences bean + definition as it stands is incomplete): + + + + + + + +   + + + + + In the preceding example, the singleton bean (userManager) is injected with a reference to the HTTP + Session-scoped bean (userPreferences). The salient point here is that the userManager bean is a + singleton: it is instantiated exactly once per container, and its dependencies (in this case only one, + the userPreferences bean) are also injected only once. This means that the userManager bean + operates only on the exact same userPreferences object (that is, the one with which it was originally + injected). + + This is not the behavior you want when injecting a shorter-lived scoped bean into a longer-lived + scoped bean (for example, injecting an HTTP Session-scoped collaborating bean as a dependency + into singleton bean). Rather, you need a single userManager object, and, for the lifetime of an HTTP + Session, you need a userPreferences object that is specific to the HTTP Session. Thus, the container + + creates an object that exposes the exact same public interface as the UserPreferences class (ideally + an object that is a UserPreferences instance), which can fetch the real UserPreferences object from + the scoping mechanism (HTTP request, Session, and so forth). The container injects this proxy + object into the userManager bean, which is unaware that this UserPreferences reference is a proxy. In + this example, when a UserManager instance invokes a method on the dependency-injected + UserPreferences object, it is actually invoking a method on the proxy. The proxy then fetches the + real UserPreferences object from (in this case) the HTTP Session and delegates the method + invocation onto the retrieved real UserPreferences object. + + Thus, you need the following (correct and complete) configuration when injecting request- and + session-scoped beans into collaborating objects, as the following example shows: + + + + +   + + + + +   + + + + + + Choosing the Type of Proxy to Create + + By default, when the Spring container creates a proxy for a bean that is marked up with the + element, a CGLIB-based class proxy is created. + + + CGLIB proxies intercept only public method calls! Do not call non-public methods +  on such a proxy. They are not delegated to the actual scoped target object. + + + Alternatively, you can configure the Spring container to create standard JDK interface-based + proxies for such scoped beans, by specifying false for the value of the proxy-target-class attribute + of the element. Using JDK interface-based proxies means that you do not need + additional libraries in your application classpath to affect such proxying. However, it also means + that the class of the scoped bean must implement at least one interface and that all collaborators + into which the scoped bean is injected must reference the bean through one of its interfaces. The + following example shows a proxy based on an interface: + + + + + +   + + + + +   + + + + + For more detailed information about choosing class-based or interface-based proxying, see + Proxying Mechanisms. + + Custom Scopes + + The bean scoping mechanism is extensible. You can define your own scopes or even redefine + existing scopes, although the latter is considered bad practice and you cannot override the built-in + singleton and prototype scopes. + + + + Creating a Custom Scope + + To integrate your custom scopes into the Spring container, you need to implement the + org.springframework.beans.factory.config.Scope interface, which is described in this section. For an + idea of how to implement your own scopes, see the Scope implementations that are supplied with + the Spring Framework itself and the Scope javadoc, which explains the methods you need to + implement in more detail. + + + The Scope interface has four methods to get objects from the scope, remove them from the scope, + and let them be destroyed. + + + The session scope implementation, for example, returns the session-scoped bean (if it does not + exist, the method returns a new instance of the bean, after having bound it to the session for future + reference). The following method returns the object from the underlying scope: + + + Java + + + Object get(String name, ObjectFactory objectFactory) + + + + Kotlin + + + fun get(name: String, objectFactory: ObjectFactory<*>): Any + + + + The session scope implementation, for example, removes the session-scoped bean from the + underlying session. The object should be returned, but you can return null if the object with the + specified name is not found. The following method removes the object from the underlying scope: + + + Java + + + Object remove(String name) + + + + Kotlin + + + fun remove(name: String): Any + + + + The following method registers a callback that the scope should invoke when it is destroyed or + when the specified object in the scope is destroyed: + + + Java + + + void registerDestructionCallback(String name, Runnable destructionCallback) + + Kotlin + + + fun registerDestructionCallback(name: String, destructionCallback: Runnable) + + + + See the javadoc or a Spring scope implementation for more information on destruction callbacks. + + The following method obtains the conversation identifier for the underlying scope: + + + Java + + + String getConversationId() + + + + Kotlin + + + fun getConversationId(): String + + + + This identifier is different for each scope. For a session scoped implementation, this identifier can + be the session identifier. + + + + Using a Custom Scope + + After you write and test one or more custom Scope implementations, you need to make the Spring + container aware of your new scopes. The following method is the central method to register a new + Scope with the Spring container: + + + Java + + + void registerScope(String scopeName, Scope scope); + + + + Kotlin + + + fun registerScope(scopeName: String, scope: Scope) + + + + This method is declared on the ConfigurableBeanFactory interface, which is available through the + BeanFactory property on most of the concrete ApplicationContext implementations that ship with + Spring. + + + The first argument to the registerScope(..) method is the unique name associated with a scope. + Examples of such names in the Spring container itself are singleton and prototype. The second + argument to the registerScope(..) method is an actual instance of the custom Scope + implementation that you wish to register and use. + + Suppose that you write your custom Scope implementation, and then register it as shown in the + next example. + + The next example uses SimpleThreadScope, which is included with Spring but is not +  registered by default. The instructions would be the same for your own custom + Scope implementations. + + + Java + + + Scope threadScope = new SimpleThreadScope(); + beanFactory.registerScope("thread", threadScope); + + + + Kotlin + + + val threadScope = SimpleThreadScope() + beanFactory.registerScope("thread", threadScope) + + + + You can then create bean definitions that adhere to the scoping rules of your custom Scope, as + follows: + + + + + + + + With a custom Scope implementation, you are not limited to programmatic registration of the scope. + You can also do the Scope registration declaratively, by using the CustomScopeConfigurer class, as the + following example shows: + + + + + +   +   +   +   +   +   +   +   +   + + +   +   +   +   + + +   +   +   + + + + + + + + When you place within a declaration for a FactoryBean +  implementation, it is the factory bean itself that is scoped, not the object returned + from getObject(). + + + 2.1.6. Customizing the Nature of a Bean + + The Spring Framework provides a number of interfaces you can use to customize the nature of a + bean. This section groups them as follows: + + • Lifecycle Callbacks + + • ApplicationContextAware and BeanNameAware + + • Other Aware Interfaces + + + Lifecycle Callbacks + + To interact with the container’s management of the bean lifecycle, you can implement the Spring + InitializingBean and DisposableBean interfaces. The container calls afterPropertiesSet() for the + + former and destroy() for the latter to let the bean perform certain actions upon initialization and + destruction of your beans. + + + The JSR-250 @PostConstruct and @PreDestroy annotations are generally considered + best practice for receiving lifecycle callbacks in a modern Spring application. Using + these annotations means that your beans are not coupled to Spring-specific +  interfaces. For details, see Using @PostConstruct and @PreDestroy. + + + If you do not want to use the JSR-250 annotations but you still want to remove + coupling, consider init-method and destroy-method bean definition metadata. + + + Internally, the Spring Framework uses BeanPostProcessor implementations to process any callback + interfaces it can find and call the appropriate methods. If you need custom features or other + lifecycle behavior Spring does not by default offer, you can implement a BeanPostProcessor yourself. + For more information, see Container Extension Points. + + + In addition to the initialization and destruction callbacks, Spring-managed objects may also + implement the Lifecycle interface so that those objects can participate in the startup and shutdown + process, as driven by the container’s own lifecycle. + + + The lifecycle callback interfaces are described in this section. + + + + Initialization Callbacks + + The org.springframework.beans.factory.InitializingBean interface lets a bean perform + initialization work after the container has set all necessary properties on the bean. The + InitializingBean interface specifies a single method: + + + + void afterPropertiesSet() throws Exception; + + + + We recommend that you do not use the InitializingBean interface, because it unnecessarily couples + the code to Spring. Alternatively, we suggest using the @PostConstruct annotation or specifying a + POJO initialization method. In the case of XML-based configuration metadata, you can use the init- + method attribute to specify the name of the method that has a void no-argument signature. With + Java configuration, you can use the initMethod attribute of @Bean. See Receiving Lifecycle Callbacks. + Consider the following example: + + + + + + + + Java + + + public class ExampleBean { + + +   public void init() { +   // do some initialization work +   } + } + + Kotlin + + + class ExampleBean { + + +   fun init() { +   // do some initialization work +   } + } + + + + The preceding example has almost exactly the same effect as the following example (which consists + of two listings): + + + + + + + + Java + + + public class AnotherExampleBean implements InitializingBean { + + +   @Override +   public void afterPropertiesSet() { +   // do some initialization work +   } + } + + + + Kotlin + + + class AnotherExampleBean : InitializingBean { + + +   override fun afterPropertiesSet() { +   // do some initialization work +   } + } + + + + However, the first of the two preceding examples does not couple the code to Spring. + + + + Destruction Callbacks + + Implementing the org.springframework.beans.factory.DisposableBean interface lets a bean get a + callback when the container that contains it is destroyed. The DisposableBean interface specifies a + single method: + + + + void destroy() throws Exception; + + + + We recommend that you do not use the DisposableBean callback interface, because it unnecessarily + couples the code to Spring. Alternatively, we suggest using the @PreDestroy annotation or specifying + a generic method that is supported by bean definitions. With XML-based configuration metadata, + you can use the destroy-method attribute on the . With Java configuration, you can use the + + destroyMethod attribute of @Bean. See Receiving Lifecycle Callbacks. Consider the following + definition: + + + + + + + + Java + + + public class ExampleBean { + + +   public void cleanup() { +   // do some destruction work (like releasing pooled connections) +   } + } + + + + Kotlin + + + class ExampleBean { + + +   fun cleanup() { +   // do some destruction work (like releasing pooled connections) +   } + } + + + + The preceding definition has almost exactly the same effect as the following definition: + + + + + + + + Java + + + public class AnotherExampleBean implements DisposableBean { + + +   @Override +   public void destroy() { +   // do some destruction work (like releasing pooled connections) +   } + } + + + + Kotlin + + + class AnotherExampleBean : DisposableBean { + + +   override fun destroy() { +   // do some destruction work (like releasing pooled connections) +   } + } + + However, the first of the two preceding definitions does not couple the code to Spring. + + + You can assign the destroy-method attribute of a element a special (inferred) + value, which instructs Spring to automatically detect a public close or shutdown + method on the specific bean class. (Any class that implements + java.lang.AutoCloseable or java.io.Closeable would therefore match.) You can +  also set this special (inferred) value on the default-destroy-method attribute of a + element to apply this behavior to an entire set of beans (see Default + Initialization and Destroy Methods). Note that this is the default behavior with Java + configuration. + + + + Default Initialization and Destroy Methods + + When you write initialization and destroy method callbacks that do not use the Spring-specific + InitializingBean and DisposableBean callback interfaces, you typically write methods with names + such as init(), initialize(), dispose(), and so on. Ideally, the names of such lifecycle callback + methods are standardized across a project so that all developers use the same method names and + ensure consistency. + + + You can configure the Spring container to “look” for named initialization and destroy callback + method names on every bean. This means that you, as an application developer, can write your + application classes and use an initialization callback called init(), without having to configure an + init-method="init" attribute with each bean definition. The Spring IoC container calls that method + when the bean is created (and in accordance with the standard lifecycle callback contract described + previously). This feature also enforces a consistent naming convention for initialization and destroy + method callbacks. + + Suppose that your initialization callback methods are named init() and your destroy callback + methods are named destroy(). Your class then resembles the class in the following example: + + + Java + + + public class DefaultBlogService implements BlogService { + + +   private BlogDao blogDao; + + +   public void setBlogDao(BlogDao blogDao) { +   this.blogDao = blogDao; +   } + + +   // this is (unsurprisingly) the initialization callback method +   public void init() { +   if (this.blogDao == null) { +   throw new IllegalStateException("The [blogDao] property must be set."); +   } +   } + } + + Kotlin + + + class DefaultBlogService : BlogService { + + +   private var blogDao: BlogDao? = null + + +   // this is (unsurprisingly) the initialization callback method +   fun init() { +   if (blogDao == null) { +   throw IllegalStateException("The [blogDao] property must be set.") +   } +   } + } + + + + You could then use that class in a bean resembling the following: + + + + + + +   +   +   + + + + + + + The presence of the default-init-method attribute on the top-level element attribute causes + the Spring IoC container to recognize a method called init on the bean class as the initialization + method callback. When a bean is created and assembled, if the bean class has such a method, it is + invoked at the appropriate time. + + + You can configure destroy method callbacks similarly (in XML, that is) by using the default- + destroy-method attribute on the top-level element. + + + Where existing bean classes already have callback methods that are named at variance with the + convention, you can override the default by specifying (in XML, that is) the method name by using + the init-method and destroy-method attributes of the itself. + + + The Spring container guarantees that a configured initialization callback is called immediately after + a bean is supplied with all dependencies. Thus, the initialization callback is called on the raw bean + reference, which means that AOP interceptors and so forth are not yet applied to the bean. A target + bean is fully created first and then an AOP proxy (for example) with its interceptor chain is applied. + If the target bean and the proxy are defined separately, your code can even interact with the raw + target bean, bypassing the proxy. Hence, it would be inconsistent to apply the interceptors to the + init method, because doing so would couple the lifecycle of the target bean to its proxy or + interceptors and leave strange semantics when your code interacts directly with the raw target + bean. \ No newline at end of file diff --git a/models/spring-ai-openai-sdk/src/test/script/deploy-microsoft-foundry-models.sh b/models/spring-ai-openai-sdk/src/test/script/deploy-microsoft-foundry-models.sh new file mode 100755 index 00000000000..f4c200ae2d2 --- /dev/null +++ b/models/spring-ai-openai-sdk/src/test/script/deploy-microsoft-foundry-models.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash + +# Execute this script to deploy the needed Microsoft Foundry models to execute the integration tests. +# +# For this, you need to have Azure CLI installed: https://learn.microsoft.com/cli/azure/install-azure-cli +# +# Azure CLI runs on: +# - Windows (using Windows Command Prompt (CMD), PowerShell, or Windows Subsystem for Linux (WSL)): https://learn.microsoft.com/cli/azure/install-azure-cli-windows +# - macOS: https://learn.microsoft.com/cli/azure/install-azure-cli-macos +# - Linux: https://learn.microsoft.com/cli/azure/install-azure-cli-linux +# - Docker: https://learn.microsoft.com/cli/azure/run-azure-cli-docker +# +# Once installed, you can run the following commands to check your installation is correct: +# az --version +# az --help + +echo "Setting up environment variables..." +echo "----------------------------------" +PROJECT="spring-ai-open-ai-sdk-$RANDOM-$RANDOM-$RANDOM" +RESOURCE_GROUP="rg-$PROJECT" +LOCATION="eastus" +AI_SERVICE="ai-$PROJECT" +TAG="$PROJECT" + +echo "Creating the resource group..." +echo "------------------------------" +az group create \ + --name "$RESOURCE_GROUP" \ + --location "$LOCATION" \ + --tags system="$TAG" + +# If you want to know the available SKUs, run the following Azure CLI command: +# az cognitiveservices account list-skus --location "$LOCATION" -o table + +echo "Creating the Cognitive Service..." +echo "---------------------------------" +az cognitiveservices account create \ + --name "$AI_SERVICE" \ + --resource-group "$RESOURCE_GROUP" \ + --location "$LOCATION" \ + --custom-domain "$AI_SERVICE" \ + --tags system="$TAG" \ + --kind "OpenAI" \ + --sku "S0" + +# If you want to know the available models, run the following Azure CLI command: +# az cognitiveservices account list-models --resource-group "$RESOURCE_GROUP" --name "$AI_SERVICE" -o table + +echo "Deploying Chat Models" +echo "==========================" + +models=("gpt-5" "gpt-5-mini" "gpt-4o-audio-preview") +versions=("2025-08-07" "2025-08-07" "2024-12-17") +skus=("GlobalStandard" "GlobalStandard" "GlobalStandard") + +for i in "${!models[@]}"; do + model="${models[$i]}" + sku="${skus[$i]}" + version="${versions[$i]}" + echo "Deploying $model..." + az cognitiveservices account deployment create \ + --name "$AI_SERVICE" \ + --resource-group "$RESOURCE_GROUP" \ + --deployment-name "$model" \ + --model-name "$model" \ + --model-version "$version"\ + --model-format "OpenAI" \ + --sku-capacity 1 \ + --sku-name "$sku" || echo "Failed to deploy $model. Check SKU and region compatibility." +done + +echo "Deploying Embedding Models" +echo "==========================" + +models=("text-embedding-ada-002" "text-embedding-3-small" "text-embedding-3-large") +versions=("2" "1" "1") +skus=("Standard" "Standard" "Standard") + +for i in "${!models[@]}"; do + model="${models[$i]}" + sku="${skus[$i]}" + version="${versions[$i]}" + echo "Deploying $model..." + az cognitiveservices account deployment create \ + --name "$AI_SERVICE" \ + --resource-group "$RESOURCE_GROUP" \ + --deployment-name "$model" \ + --model-name "$model" \ + --model-version "$version"\ + --model-format "OpenAI" \ + --sku-capacity 1 \ + --sku-name "$sku" || echo "Failed to deploy $model. Check SKU and region compatibility." +done + +echo "Deploying Image Models" +echo "==========================" + +models=("dall-e-3") +versions=("3.0") +skus=("Standard") + +for i in "${!models[@]}"; do + model="${models[$i]}" + sku="${skus[$i]}" + version="${versions[$i]}" + echo "Deploying $model..." + az cognitiveservices account deployment create \ + --name "$AI_SERVICE" \ + --resource-group "$RESOURCE_GROUP" \ + --deployment-name "$model" \ + --model-name "$model" \ + --model-version "$version"\ + --model-format "OpenAI" \ + --sku-capacity 1 \ + --sku-name "$sku" || echo "Failed to deploy $model. Check SKU and region compatibility." +done + +echo "Storing the key and endpoint in environment variables..." +echo "--------------------------------------------------------" +OPENAI_API_KEY=$( + az cognitiveservices account keys list \ + --name "$AI_SERVICE" \ + --resource-group "$RESOURCE_GROUP" \ + | jq -r .key1 + ) +OPENAI_BASE_URL=$( + az cognitiveservices account show \ + --name "$AI_SERVICE" \ + --resource-group "$RESOURCE_GROUP" \ + | jq -r .properties.endpoint + ) + +echo "OPENAI_API_KEY=$OPENAI_API_KEY" +echo "OPENAI_BASE_URL=$OPENAI_BASE_URL" + +# Once you finish the tests, you can delete the resource group with the following command: +#echo "Deleting the resource group..." +#echo "------------------------------" +#az group delete --name "$RESOURCE_GROUP" --yes diff --git a/pom.xml b/pom.xml index 9695f90f231..fb9f8c8bcd5 100644 --- a/pom.xml +++ b/pom.xml @@ -106,6 +106,7 @@ auto-configurations/models/spring-ai-autoconfigure-model-elevenlabs auto-configurations/models/spring-ai-autoconfigure-model-huggingface auto-configurations/models/spring-ai-autoconfigure-model-openai + auto-configurations/models/spring-ai-autoconfigure-model-openai-sdk auto-configurations/models/spring-ai-autoconfigure-model-minimax auto-configurations/models/spring-ai-autoconfigure-model-mistral-ai auto-configurations/models/spring-ai-autoconfigure-model-oci-genai @@ -180,6 +181,7 @@ models/spring-ai-oci-genai models/spring-ai-ollama models/spring-ai-openai + models/spring-ai-openai-sdk models/spring-ai-postgresml models/spring-ai-stability-ai models/spring-ai-transformers @@ -203,6 +205,7 @@ spring-ai-spring-boot-starters/spring-ai-starter-model-oci-genai spring-ai-spring-boot-starters/spring-ai-starter-model-ollama spring-ai-spring-boot-starters/spring-ai-starter-model-openai + spring-ai-spring-boot-starters/spring-ai-starter-model-openai-sdk spring-ai-spring-boot-starters/spring-ai-starter-model-postgresml-embedding spring-ai-spring-boot-starters/spring-ai-starter-model-stability-ai spring-ai-spring-boot-starters/spring-ai-starter-model-transformers @@ -277,6 +280,8 @@ 4.0.0 4.3.4 1.0.0-beta.16 + 4.8.0 + 1.18.1 1.1.0 2.2.21 @@ -836,6 +841,7 @@ org.springframework.ai.mistralai/**/*IT.java org.springframework.ai.oci/**/*IT.java org.springframework.ai.ollama/**/*IT.java + org.springframework.ai.openaisdk/**/*IT.java org.springframework.ai.postgresml/**/*IT.java org.springframework.ai.stabilityai/**/*IT.java org.springframework.ai.transformers/**/*IT.java diff --git a/spring-ai-bom/pom.xml b/spring-ai-bom/pom.xml index 53b3d6aa0d5..06536153563 100644 --- a/spring-ai-bom/pom.xml +++ b/spring-ai-bom/pom.xml @@ -311,6 +311,12 @@ ${project.version} + + org.springframework.ai + spring-ai-openai-sdk + ${project.version} + + org.springframework.ai spring-ai-postgresml @@ -680,6 +686,12 @@ ${project.version} + + org.springframework.ai + spring-ai-autoconfigure-model-openai-sdk + ${project.version} + + org.springframework.ai spring-ai-autoconfigure-model-postgresml-embedding @@ -1029,6 +1041,12 @@ ${project.version} + + org.springframework.ai + spring-ai-starter-model-openai-sdk + ${project.version} + + org.springframework.ai spring-ai-starter-model-postgresml-embedding diff --git a/spring-ai-commons/src/main/java/org/springframework/ai/observation/conventions/AiProvider.java b/spring-ai-commons/src/main/java/org/springframework/ai/observation/conventions/AiProvider.java index 88105725a69..6a585339a7f 100644 --- a/spring-ai-commons/src/main/java/org/springframework/ai/observation/conventions/AiProvider.java +++ b/spring-ai-commons/src/main/java/org/springframework/ai/observation/conventions/AiProvider.java @@ -85,6 +85,11 @@ public enum AiProvider { */ OPENAI("openai"), + /** + * AI system provided by the official OpenAI SDK. + */ + OPENAI_SDK("openai_sdk"), + /** * AI system provided by Spring AI. */ diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc index d4b40d8dd3f..ff80e6763fa 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc @@ -33,6 +33,7 @@ **** xref:api/chat/perplexity-chat.adoc[Perplexity AI] **** OCI Generative AI ***** xref:api/chat/oci-genai/cohere-chat.adoc[Cohere] +**** xref:api/chat/openai-sdk-chat.adoc[OpenAI SDK (Official)] **** xref:api/chat/openai-chat.adoc[OpenAI] **** xref:api/chat/qianfan-chat.adoc[QianFan] **** xref:api/chat/zhipuai-chat.adoc[ZhiPu AI] @@ -49,6 +50,7 @@ **** xref:api/embeddings/oci-genai-embeddings.adoc[OCI GenAI] **** xref:api/embeddings/ollama-embeddings.adoc[Ollama] **** xref:api/embeddings/onnx.adoc[(ONNX) Transformers] +**** xref:api/embeddings/openai-sdk-embeddings.adoc[OpenAI SDK (Official)] **** xref:api/embeddings/openai-embeddings.adoc[OpenAI] **** xref:api/embeddings/postgresml-embeddings.adoc[PostgresML] **** xref:api/embeddings/qianfan-embeddings.adoc[QianFan] @@ -59,6 +61,7 @@ *** xref:api/imageclient.adoc[Image Models] **** xref:api/image/azure-openai-image.adoc[Azure OpenAI] +**** xref:api/image/openai-sdk-image.adoc[OpenAI SDK (Official)] **** xref:api/image/openai-image.adoc[OpenAI] **** xref:api/image/stabilityai-image.adoc[Stability] **** xref:api/image/zhipuai-image.adoc[ZhiPuAI] diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/comparison.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/comparison.adoc index 09ac7f30ed5..8cff67dd866 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/comparison.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/comparison.adoc @@ -32,6 +32,8 @@ This table compares various Chat Models supported by Spring AI, detailing their | xref::api/chat/nvidia-chat.adoc[NVIDIA (OpenAI-proxy)] | text, image ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::no.svg[width=12] ^a| image::no.svg[width=12] ^a| image::yes.svg[width=16] | xref::api/chat/oci-genai/cohere-chat.adoc[OCI GenAI/Cohere] | text ^a| image::no.svg[width=12] ^a| image::no.svg[width=12] ^a| image::no.svg[width=12] ^a| image::yes.svg[width=12] ^a| image::no.svg[width=12] ^a| image::no.svg[width=12] ^a| image::no.svg[width=12] | xref::api/chat/ollama-chat.adoc[Ollama] | text, image ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] +| xref::api/chat/openai-sdk-chat.adoc[OpenAI SDK (Official)] a| In: text, image, audio +Out: text, audio ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::no.svg[width=12] ^a| image::yes.svg[width=16] | xref::api/chat/openai-chat.adoc[OpenAI] a| In: text, image, audio Out: text, audio ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::no.svg[width=12] ^a| image::yes.svg[width=16] | xref::api/chat/perplexity-chat.adoc[Perplexity (OpenAI-proxy)] | text ^a| image::no.svg[width=12] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::no.svg[width=12] ^a| image::no.svg[width=12] ^a| image::yes.svg[width=16] diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/openai-sdk-chat.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/openai-sdk-chat.adoc new file mode 100644 index 00000000000..1c02cb69e7b --- /dev/null +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/openai-sdk-chat.adoc @@ -0,0 +1,675 @@ += OpenAI SDK Chat (Official) + +Spring AI supports OpenAI's language models through the OpenAI Java SDK, providing a robust and officially-maintained integration with OpenAI's services including Microsoft Foundry and GitHub Models. + +NOTE: This implementation uses the official link:https://github.com/openai/openai-java[OpenAI Java SDK] from OpenAI. For the alternative Spring AI implementation, see xref:api/chat/openai-chat.adoc[OpenAI Chat]. + +The OpenAI SDK module automatically detects the service provider (OpenAI, Microsoft Foundry, or GitHub Models) based on the base URL you provide. + +== Authentication + +Authentication is done using a base URL and an API Key. The implementation provides flexible configuration options through Spring Boot properties or environment variables. + + +=== Using OpenAI + +If you are using OpenAI directly, create an account at https://platform.openai.com/signup[OpenAI signup page] and generate an API key on the https://platform.openai.com/account/api-keys[API Keys page]. + +The base URL doesn't need to be set as it defaults to `https://api.openai.com/v1`: + +[source,properties] +---- +spring.ai.openai-sdk.api-key= +# base-url is optional, defaults to https://api.openai.com/v1 +---- + +Or using environment variables: + +[source,bash] +---- +export OPENAI_API_KEY= +# OPENAI_BASE_URL is optional, defaults to https://api.openai.com/v1 +---- + +=== Using Microsoft Foundry + +Microsoft Foundry is automatically detected when using a Microsoft Foundry URL. You can configure it using properties: + +[source,properties] +---- +spring.ai.openai-sdk.base-url=https://.openai.azure.com +spring.ai.openai-sdk.api-key= +spring.ai.openai-sdk.microsoft-deployment-name= +---- + +Or using environment variables: + +[source,bash] +---- +export OPENAI_BASE_URL=https://.openai.azure.com +export OPENAI_API_KEY= +---- + +**Passwordless Authentication (Recommended for Azure):** + +Microsoft Foundry supports passwordless authentication without providing an API key, which is more secure when running on Azure. + +To enable passwordless authentication, add the `com.azure:azure-identity` dependency: + +[source,xml] +---- + + com.azure + azure-identity + +---- + +Then configure without an API key: + +[source,properties] +---- +spring.ai.openai-sdk.base-url=https://.openai.azure.com +spring.ai.openai-sdk.microsoft-deployment-name= +# No api-key needed - will use Azure credentials from environment +---- + +=== Using GitHub Models + +GitHub Models is automatically detected when using the GitHub Models base URL. You'll need to create a GitHub Personal Access Token (PAT) with the `models:read` scope. + +[source,properties] +---- +spring.ai.openai-sdk.base-url=https://models.inference.ai.azure.com +spring.ai.openai-sdk.api-key=github_pat_XXXXXXXXXXX +---- + +Or using environment variables: + +[source,bash] +---- +export OPENAI_BASE_URL=https://models.inference.ai.azure.com +export OPENAI_API_KEY=github_pat_XXXXXXXXXXX +---- + +TIP: For enhanced security when handling sensitive information like API keys, you can use Spring Expression Language (SpEL) in your properties: + +[source,properties] +---- +spring.ai.openai-sdk.api-key=${OPENAI_API_KEY} +---- + +=== Add Repositories and BOM + +Spring AI artifacts are published in Maven Central and Spring Snapshot repositories. +Refer to the xref:getting-started.adoc#artifact-repositories[Artifact Repositories] section to add these repositories to your build system. + +To help with dependency management, Spring AI provides a BOM (bill of materials) to ensure that a consistent version of Spring AI is used throughout the entire project. Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build system. + +== Auto-configuration + +Spring AI provides Spring Boot auto-configuration for the OpenAI SDK Chat Client. +To enable it add the following dependency to your project's Maven `pom.xml` or Gradle `build.gradle` build files: + +[tabs] +====== +Maven:: ++ +[source, xml] +---- + + org.springframework.ai + spring-ai-starter-model-openai-sdk + +---- + +Gradle:: ++ +[source,groovy] +---- +dependencies { + implementation 'org.springframework.ai:spring-ai-starter-model-openai-sdk' +} +---- +====== + +TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file. + +=== Configuration Properties + +==== Connection Properties + +The prefix `spring.ai.openai-sdk` is used as the property prefix that lets you configure the OpenAI SDK client. + +[cols="3,5,1", stripes=even] +|==== +| Property | Description | Default + +| spring.ai.openai-sdk.base-url | The URL to connect to. Auto-detects from `OPENAI_BASE_URL` environment variable if not set. | https://api.openai.com/v1 +| spring.ai.openai-sdk.api-key | The API Key. Auto-detects from `OPENAI_API_KEY` environment variable if not set. | - +| spring.ai.openai-sdk.organization-id | Optionally specify which organization to use for API requests. | - +| spring.ai.openai-sdk.timeout | Request timeout duration. | - +| spring.ai.openai-sdk.max-retries | Maximum number of retry attempts for failed requests. | - +| spring.ai.openai-sdk.proxy | Proxy settings for OpenAI client (Java `Proxy` object). | - +| spring.ai.openai-sdk.custom-headers | Custom HTTP headers to include in requests. Map of header name to header value. | - +|==== + +==== Microsoft Foundry (Azure OpenAI) Properties + +The OpenAI SDK implementation provides native support for Microsoft Foundry (Azure OpenAI) with automatic configuration: + +[cols="3,5,1", stripes=even] +|==== +| Property | Description | Default + +| spring.ai.openai-sdk.microsoft-foundry | Enable Microsoft Foundry mode. Auto-detected if base URL contains `openai.azure.com`, `cognitiveservices.azure.com`, or `.openai.microsoftFoundry.com`. | false +| spring.ai.openai-sdk.microsoft-deployment-name | Microsoft Foundry deployment name. If not specified, the model name will be used. Also accessible via alias `deployment-name`. | - +| spring.ai.openai-sdk.microsoft-foundry-service-version | Microsoft Foundry API service version. | - +| spring.ai.openai-sdk.credential | Credential object for passwordless authentication (requires `com.azure:azure-identity` dependency). | - +|==== + +TIP: Microsoft Foundry supports passwordless authentication. Add the `com.azure:azure-identity` dependency and the implementation will automatically attempt to use Azure credentials from the environment when no API key is provided. + +==== GitHub Models Properties + +Native support for GitHub Models is available: + +[cols="3,5,1", stripes=even] +|==== +| Property | Description | Default + +| spring.ai.openai-sdk.github-models | Enable GitHub Models mode. Auto-detected if base URL contains `models.github.ai` or `models.inference.ai.azure.com`. | false +|==== + +TIP: GitHub Models requires a Personal Access Token with the `models:read` scope. Set it via the `OPENAI_API_KEY` environment variable or the `spring.ai.openai-sdk.api-key` property. + +==== Chat Model Properties + +The prefix `spring.ai.openai-sdk.chat` is the property prefix for configuring the chat model implementation: + +[cols="3,5,1", stripes=even] +|==== +| Property | Description | Default + +| spring.ai.openai-sdk.chat.options.model | Name of the OpenAI chat model to use. You can select between models such as: `gpt-5-mini`, `gpt-4o`, `gpt-4o-mini`, `gpt-4-turbo`, `o1`, `o3-mini`, and more. See the https://platform.openai.com/docs/models[models] page for more information. | `gpt-5-mini` +| spring.ai.openai-sdk.chat.options.temperature | The sampling temperature to use that controls the apparent creativity of generated completions. Higher values will make output more random while lower values will make results more focused and deterministic. It is not recommended to modify `temperature` and `top_p` for the same completions request as the interaction of these two settings is difficult to predict. | 1.0 +| spring.ai.openai-sdk.chat.options.frequency-penalty | Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim. | 0.0 +| spring.ai.openai-sdk.chat.options.logit-bias | Modify the likelihood of specified tokens appearing in the completion. | - +| spring.ai.openai-sdk.chat.options.logprobs | Whether to return log probabilities of the output tokens. | false +| spring.ai.openai-sdk.chat.options.top-logprobs | An integer between 0 and 5 specifying the number of most likely tokens to return at each token position. Requires `logprobs` to be true. | - +| spring.ai.openai-sdk.chat.options.max-tokens | The maximum number of tokens to generate. *Use for non-reasoning models* (e.g., gpt-4o, gpt-3.5-turbo). *Cannot be used with reasoning models* (e.g., o1, o3, o4-mini series). *Mutually exclusive with maxCompletionTokens*. | - +| spring.ai.openai-sdk.chat.options.max-completion-tokens | An upper bound for the number of tokens that can be generated for a completion, including visible output tokens and reasoning tokens. *Required for reasoning models* (e.g., o1, o3, o4-mini series). *Cannot be used with non-reasoning models*. *Mutually exclusive with maxTokens*. | - +| spring.ai.openai-sdk.chat.options.n | How many chat completion choices to generate for each input message. | 1 +| spring.ai.openai-sdk.chat.options.output-modalities | List of output modalities. Can include "text" and "audio". | - +| spring.ai.openai-sdk.chat.options.output-audio | Parameters for audio output. Use `AudioParameters` with voice (ALLOY, ASH, BALLAD, CORAL, ECHO, FABLE, ONYX, NOVA, SAGE, SHIMMER) and format (MP3, FLAC, OPUS, PCM16, WAV, AAC). | - +| spring.ai.openai-sdk.chat.options.presence-penalty | Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far. | 0.0 +| spring.ai.openai-sdk.chat.options.response-format.type | Response format type: `TEXT`, `JSON_OBJECT`, or `JSON_SCHEMA`. | TEXT +| spring.ai.openai-sdk.chat.options.response-format.json-schema | JSON schema for structured outputs when type is `JSON_SCHEMA`. | - +| spring.ai.openai-sdk.chat.options.seed | If specified, the system will make a best effort to sample deterministically for reproducible results. | - +| spring.ai.openai-sdk.chat.options.stop | Up to 4 sequences where the API will stop generating further tokens. | - +| spring.ai.openai-sdk.chat.options.top-p | An alternative to sampling with temperature, called nucleus sampling. | - +| spring.ai.openai-sdk.chat.options.user | A unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse. | - +| spring.ai.openai-sdk.chat.options.parallel-tool-calls | Whether to enable parallel function calling during tool use. | true +| spring.ai.openai-sdk.chat.options.reasoning-effort | Constrains effort on reasoning for reasoning models: `low`, `medium`, or `high`. | - +| spring.ai.openai-sdk.chat.options.verbosity | Controls the verbosity of the model's response. | - +| spring.ai.openai-sdk.chat.options.store | Whether to store the output of this chat completion request for use in OpenAI's model distillation or evals products. | false +| spring.ai.openai-sdk.chat.options.metadata | Developer-defined tags and values used for filtering completions in the dashboard. | - +| spring.ai.openai-sdk.chat.options.service-tier | Specifies the latency tier to use: `auto`, `default`, `flex`, or `priority`. | - +| spring.ai.openai-sdk.chat.options.stream-options.include-usage | Whether to include usage statistics in streaming responses. | false +| spring.ai.openai-sdk.chat.options.stream-options.include-obfuscation | Whether to include obfuscation in streaming responses. | false +| spring.ai.openai-sdk.chat.options.tool-choice | Controls which (if any) function is called by the model. | - +| spring.ai.openai-sdk.chat.options.internal-tool-execution-enabled | If false, Spring AI will proxy tool calls to the client for manual handling. If true (default), Spring AI handles function calls internally. | true +|==== + +[NOTE] +==== +When using GPT-5 models such as `gpt-5`, `gpt-5-mini`, and `gpt-5-nano`, the `temperature` parameter is not supported. +These models are optimized for reasoning and do not use temperature. +Specifying a temperature value will result in an error. +In contrast, conversational models like `gpt-5-chat` do support the `temperature` parameter. +==== + +TIP: All properties prefixed with `spring.ai.openai-sdk.chat.options` can be overridden at runtime by adding request-specific <> to the `Prompt` call. + +=== Token Limit Parameters: Model-Specific Usage + +OpenAI provides two mutually exclusive parameters for controlling token generation limits: + +[cols="2,3,3", stripes=even] +|==== +| Parameter | Use Case | Compatible Models + +| `maxTokens` | Non-reasoning models | gpt-4o, gpt-4o-mini, gpt-4-turbo, gpt-3.5-turbo +| `maxCompletionTokens` | Reasoning models | o1, o1-mini, o1-preview, o3, o4-mini series +|==== + +IMPORTANT: These parameters are **mutually exclusive**. Setting both will result in an API error from OpenAI. + +==== Usage Examples + +**For non-reasoning models (gpt-4o, gpt-3.5-turbo):** +[source,java] +---- +ChatResponse response = chatModel.call( + new Prompt( + "Explain quantum computing in simple terms.", + OpenAiSdkChatOptions.builder() + .model("gpt-4o") + .maxTokens(150) // Use maxTokens for non-reasoning models + .build() + )); +---- + +**For reasoning models (o1, o3 series):** +[source,java] +---- +ChatResponse response = chatModel.call( + new Prompt( + "Solve this complex math problem step by step: ...", + OpenAiSdkChatOptions.builder() + .model("o1-preview") + .maxCompletionTokens(1000) // Use maxCompletionTokens for reasoning models + .build() + )); +---- + +== Runtime Options [[chat-options]] + +The https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/OpenAiSdkChatOptions.java[OpenAiSdkChatOptions.java] class provides model configurations such as the model to use, the temperature, the frequency penalty, etc. + +On start-up, the default options can be configured with the `OpenAiSdkChatModel(options)` constructor or the `spring.ai.openai-sdk.chat.options.*` properties. + +At run-time, you can override the default options by adding new, request-specific options to the `Prompt` call. +For example, to override the default model and temperature for a specific request: + +[source,java] +---- +ChatResponse response = chatModel.call( + new Prompt( + "Generate the names of 5 famous pirates.", + OpenAiSdkChatOptions.builder() + .model("gpt-4o") + .temperature(0.4) + .build() + )); +---- + +TIP: In addition to the model specific https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/OpenAiSdkChatOptions.java[OpenAiSdkChatOptions] you can use a portable link:https://github.com/spring-projects/spring-ai/blob/main/spring-ai-model/src/main/java/org/springframework/ai/chat/prompt/ChatOptions.java[ChatOptions] instance, created with link:https://github.com/spring-projects/spring-ai/blob/main/spring-ai-model/src/main/java/org/springframework/ai/chat/prompt/DefaultChatOptionsBuilder.java[ChatOptions#builder()]. + +== Tool Calling + +You can register custom Java functions or methods with the `OpenAiSdkChatModel` and have the OpenAI model intelligently choose to output a JSON object containing arguments to call one or many of the registered functions/tools. +This is a powerful technique to connect the LLM capabilities with external tools and APIs. +Read more about xref:api/tools.adoc[Tool Calling]. + +Example usage: + +[source,java] +---- +var chatOptions = OpenAiSdkChatOptions.builder() + .toolCallbacks(List.of( + FunctionToolCallback.builder("getCurrentWeather", new WeatherService()) + .description("Get the weather in location") + .inputType(WeatherService.Request.class) + .build())) + .build(); + +ChatResponse response = chatModel.call( + new Prompt("What's the weather like in San Francisco?", chatOptions)); +---- + +== Multimodal + +Multimodality refers to a model's ability to simultaneously understand and process information from various sources, including text, images, audio, and other data formats. + +=== Vision + +OpenAI models that offer vision multimodal support include `gpt-4`, `gpt-4o`, and `gpt-4o-mini`. +Refer to the link:https://platform.openai.com/docs/guides/vision[Vision] guide for more information. + +Spring AI's link:https://github.com/spring-projects/spring-ai/blob/main/spring-ai-model/src/main/java/org/springframework/ai/chat/messages/Message.java[Message] interface facilitates multimodal AI models by introducing the link:https://github.com/spring-projects/spring-ai/blob/main/spring-ai-commons/src/main/java/org/springframework/ai/content/Media.java[Media] type. + +Below is a code example illustrating the fusion of user text with an image: + +[source,java] +---- +var imageResource = new ClassPathResource("/multimodal.test.png"); + +var userMessage = new UserMessage( + "Explain what do you see on this picture?", + List.of(new Media(MimeTypeUtils.IMAGE_PNG, imageResource))); + +ChatResponse response = chatModel.call( + new Prompt(userMessage, + OpenAiSdkChatOptions.builder() + .model("gpt-4o") + .build())); +---- + +Or using an image URL: + +[source,java] +---- +var userMessage = new UserMessage( + "Explain what do you see on this picture?", + List.of(Media.builder() + .mimeType(MimeTypeUtils.IMAGE_PNG) + .data(URI.create("https://docs.spring.io/spring-ai/reference/_images/multimodal.test.png")) + .build())); + +ChatResponse response = chatModel.call(new Prompt(userMessage)); +---- + +TIP: You can pass multiple images as well. + +=== Audio + +OpenAI models that offer audio input support include `gpt-4o-audio-preview`. +Refer to the link:https://platform.openai.com/docs/guides/audio[Audio] guide for more information. + +Spring AI supports base64-encoded audio files with the message. +Currently, OpenAI supports the following media types: `audio/mp3` and `audio/wav`. + +Example of audio input: + +[source,java] +---- +var audioResource = new ClassPathResource("speech1.mp3"); + +var userMessage = new UserMessage( + "What is this recording about?", + List.of(new Media(MimeTypeUtils.parseMimeType("audio/mp3"), audioResource))); + +ChatResponse response = chatModel.call( + new Prompt(userMessage, + OpenAiSdkChatOptions.builder() + .model("gpt-4o-audio-preview") + .build())); +---- + +=== Output Audio + +The `gpt-4o-audio-preview` model can generate audio responses. + +Example of generating audio output: + +[source,java] +---- +var userMessage = new UserMessage("Tell me a joke about Spring Framework"); + +ChatResponse response = chatModel.call( + new Prompt(userMessage, + OpenAiSdkChatOptions.builder() + .model("gpt-4o-audio-preview") + .outputModalities(List.of("text", "audio")) + .outputAudio(new AudioParameters(Voice.ALLOY, AudioResponseFormat.WAV)) + .build())); + +String text = response.getResult().getOutput().getContent(); // audio transcript +byte[] waveAudio = response.getResult().getOutput().getMedia().get(0).getDataAsByteArray(); // audio data +---- + +== Structured Outputs + +OpenAI provides custom https://platform.openai.com/docs/guides/structured-outputs[Structured Outputs] APIs that ensure your model generates responses conforming strictly to your provided `JSON Schema`. + +=== Configuration + +You can set the response format programmatically with the `OpenAiSdkChatOptions` builder: + +[source,java] +---- +String jsonSchema = """ + { + "type": "object", + "properties": { + "steps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "explanation": { "type": "string" }, + "output": { "type": "string" } + }, + "required": ["explanation", "output"], + "additionalProperties": false + } + }, + "final_answer": { "type": "string" } + }, + "required": ["steps", "final_answer"], + "additionalProperties": false + } + """; + +Prompt prompt = new Prompt( + "how can I solve 8x + 7 = -23", + OpenAiSdkChatOptions.builder() + .model("gpt-4o-mini") + .responseFormat(ResponseFormat.builder() + .type(ResponseFormat.Type.JSON_SCHEMA) + .jsonSchema(jsonSchema) + .build()) + .build()); + +ChatResponse response = chatModel.call(prompt); +---- + +=== Integrating with BeanOutputConverter + +You can leverage existing xref::api/structured-output-converter.adoc#_bean_output_converter[BeanOutputConverter] utilities: + +[source,java] +---- +record MathReasoning( + @JsonProperty(required = true, value = "steps") Steps steps, + @JsonProperty(required = true, value = "final_answer") String finalAnswer) { + + record Steps( + @JsonProperty(required = true, value = "items") Items[] items) { + + record Items( + @JsonProperty(required = true, value = "explanation") String explanation, + @JsonProperty(required = true, value = "output") String output) { + } + } +} + +var outputConverter = new BeanOutputConverter<>(MathReasoning.class); +String jsonSchema = outputConverter.getJsonSchema(); + +Prompt prompt = new Prompt( + "how can I solve 8x + 7 = -23", + OpenAiSdkChatOptions.builder() + .model("gpt-4o-mini") + .responseFormat(ResponseFormat.builder() + .type(ResponseFormat.Type.JSON_SCHEMA) + .jsonSchema(jsonSchema) + .build()) + .build()); + +ChatResponse response = chatModel.call(prompt); +MathReasoning mathReasoning = outputConverter.convert( + response.getResult().getOutput().getContent()); +---- + +== Sample Controller + +https://start.spring.io/[Create] a new Spring Boot project and add the `spring-ai-openai-sdk` to your pom (or gradle) dependencies. + +Add an `application.properties` file under the `src/main/resources` directory to configure the OpenAI SDK chat model: + +[source,application.properties] +---- +spring.ai.openai-sdk.api-key=YOUR_API_KEY +spring.ai.openai-sdk.chat.options.model=gpt-5-mini +spring.ai.openai-sdk.chat.options.temperature=0.7 +---- + +TIP: Replace the `api-key` with your OpenAI credentials. + +This will create an `OpenAiSdkChatModel` implementation that you can inject into your classes. +Here is an example of a simple `@RestController` class that uses the chat model for text generations. + +[source,java] +---- +@RestController +public class ChatController { + + private final OpenAiSdkChatModel chatModel; + + @Autowired + public ChatController(OpenAiSdkChatModel chatModel) { + this.chatModel = chatModel; + } + + @GetMapping("/ai/generate") + public Map generate( + @RequestParam(value = "message", defaultValue = "Tell me a joke") String message) { + return Map.of("generation", chatModel.call(message)); + } + + @GetMapping("/ai/generateStream") + public Flux generateStream( + @RequestParam(value = "message", defaultValue = "Tell me a joke") String message) { + Prompt prompt = new Prompt(new UserMessage(message)); + return chatModel.stream(prompt); + } +} +---- + +== Manual Configuration + +The https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/OpenAiSdkChatModel.java[OpenAiSdkChatModel] implements the `ChatModel` and uses the official OpenAI Java SDK to connect to the OpenAI service. + +Add the `spring-ai-openai-sdk` dependency to your project's Maven `pom.xml` file: + +[source, xml] +---- + + org.springframework.ai + spring-ai-openai-sdk + +---- + +or to your Gradle `build.gradle` build file: + +[source,groovy] +---- +dependencies { + implementation 'org.springframework.ai:spring-ai-openai-sdk' +} +---- + +TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file. + +Next, create an `OpenAiSdkChatModel` and use it for text generations: + +[source,java] +---- +var chatOptions = OpenAiSdkChatOptions.builder() + .model("gpt-4o") + .temperature(0.7) + .apiKey(System.getenv("OPENAI_API_KEY")) + .build(); + +var chatModel = new OpenAiSdkChatModel(chatOptions); + +ChatResponse response = chatModel.call( + new Prompt("Generate the names of 5 famous pirates.")); + +// Or with streaming responses +Flux response = chatModel.stream( + new Prompt("Generate the names of 5 famous pirates.")); +---- + +=== Microsoft Foundry Configuration + +For Microsoft Foundry : + +[source,java] +---- +var chatOptions = OpenAiSdkChatOptions.builder() + .baseUrl("https://your-resource.openai.azure.com") + .apiKey(System.getenv("OPENAI_API_KEY")) + .deploymentName("gpt-4") + .azureOpenAIServiceVersion(AzureOpenAIServiceVersion.V2024_10_01_PREVIEW) + .azure(true) // Enables Microsoft Foundry mode + .build(); + +var chatModel = new OpenAiSdkChatModel(chatOptions); +---- + +TIP: Microsoft Foundry supports passwordless authentication. Add the `com.azure:azure-identity` dependency to your project. If you don't provide an API key, the implementation will automatically attempt to use Azure credentials from your environment. + +=== GitHub Models Configuration + +For GitHub Models: + +[source,java] +---- +var chatOptions = OpenAiSdkChatOptions.builder() + .baseUrl("https://models.inference.ai.azure.com") + .apiKey(System.getenv("GITHUB_TOKEN")) + .model("gpt-4o") + .githubModels(true) + .build(); + +var chatModel = new OpenAiSdkChatModel(chatOptions); +---- + +== Key Differences from Spring AI OpenAI + +This implementation differs from the xref:api/chat/openai-chat.adoc[Spring AI OpenAI] implementation in several ways: + +[cols="2,3,3", stripes=even] +|==== +| Aspect | Official OpenAI SDK | Existing OpenAI + +| **HTTP Client** | OkHttp (via official SDK) | Spring RestClient/WebClient +| **API Updates** | Automatic via SDK updates | Manual maintenance +| **Azure Support** | Native with passwordless auth | Manual URL construction +| **GitHub Models** | Native support | Not supported +| **Audio/Moderation** | Not yet supported | Fully supported +| **Retry Logic** | SDK-managed (exponential backoff) | Spring Retry (customizable) +| **Dependencies** | Official OpenAI SDK | Spring WebFlux +|==== + +**When to use OpenAI SDK:** + +* You're starting a new project +* You primarily use Microsoft Foundry or GitHub Models +* You want automatic API updates from OpenAI +* You don't need audio transcription or moderation features +* You prefer official SDK support + +**When to use Spring AI OpenAI:** + +* You have an existing project using it +* You need audio transcription or moderation features +* You require fine-grained HTTP control +* You want native Spring reactive support +* You need custom retry strategies + +== Observability + +The OpenAI SDK implementation supports Spring AI's observability features through Micrometer. +All chat model operations are instrumented for monitoring and tracing. + +== Limitations + +The following features are not yet supported in the OpenAI SDK implementation: + +* Audio speech generation (TTS) +* Audio transcription +* Moderation API +* File API operations + +These features are available in the xref:api/chat/openai-chat.adoc[Spring AI OpenAI] implementation. + +== Additional Resources + +* link:https://github.com/openai/openai-java[Official OpenAI Java SDK] +* link:https://platform.openai.com/docs/api-reference/chat[OpenAI Chat API Documentation] +* link:https://platform.openai.com/docs/models[OpenAI Models] +* link:https://learn.microsoft.com/en-us/azure/ai-foundry/[Microsoft Foundry Documentation] +* link:https://github.com/marketplace/models[GitHub Models] diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/openai-sdk-embeddings.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/openai-sdk-embeddings.adoc new file mode 100644 index 00000000000..d2c0b8320f1 --- /dev/null +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/openai-sdk-embeddings.adoc @@ -0,0 +1,357 @@ += OpenAI SDK Embeddings (Official) + +Spring AI supports OpenAI's text embeddings models through the OpenAI Java SDK, providing a robust and officially-maintained integration with OpenAI's services including Microsoft Foundry and GitHub Models. + +NOTE: This implementation uses the official link:https://github.com/openai/openai-java[OpenAI Java SDK] from OpenAI. For the alternative Spring AI implementation, see xref:api/embeddings/openai-embeddings.adoc[OpenAI Embeddings]. + +OpenAI's text embeddings measure the relatedness of text strings. +An embedding is a vector (list) of floating point numbers. The distance between two vectors measures their relatedness. Small distances suggest high relatedness and large distances suggest low relatedness. + +The OpenAI SDK module automatically detects the service provider (OpenAI, Microsoft Foundry, or GitHub Models) based on the base URL you provide. + +== Authentication + +Authentication is done using a base URL and an API Key. The implementation provides flexible configuration options through Spring Boot properties or environment variables. + +=== Using OpenAI + +If you are using OpenAI directly, create an account at https://platform.openai.com/signup[OpenAI signup page] and generate an API key on the https://platform.openai.com/account/api-keys[API Keys page]. + +The base URL doesn't need to be set as it defaults to `https://api.openai.com/v1`: + +[source,properties] +---- +spring.ai.openai-sdk.api-key= +# base-url is optional, defaults to https://api.openai.com/v1 +---- + +Or using environment variables: + +[source,bash] +---- +export OPENAI_API_KEY= +# OPENAI_BASE_URL is optional, defaults to https://api.openai.com/v1 +---- + +=== Using Microsoft Foundry + +Microsoft Foundry is automatically detected when using a Microsoft Foundry URL. You can configure it using properties: + +[source,properties] +---- +spring.ai.openai-sdk.base-url=https://.openai.azure.com +spring.ai.openai-sdk.api-key= +spring.ai.openai-sdk.microsoft-deployment-name= +---- + +Or using environment variables: + +[source,bash] +---- +export OPENAI_BASE_URL=https://.openai.azure.com +export OPENAI_API_KEY= +---- + +**Passwordless Authentication (Recommended for Azure):** + +Microsoft Foundry supports passwordless authentication without providing an API key, which is more secure when running on Azure. + +To enable passwordless authentication, add the `com.azure:azure-identity` dependency: + +[source,xml] +---- + + com.azure + azure-identity + +---- + +Then configure without an API key: + +[source,properties] +---- +spring.ai.openai-sdk.base-url=https://.openai.azure.com +spring.ai.openai-sdk.microsoft-deployment-name= +# No api-key needed - will use Azure credentials from environment +---- + +=== Using GitHub Models + +GitHub Models is automatically detected when using the GitHub Models base URL. You'll need to create a GitHub Personal Access Token (PAT) with the `models:read` scope. + +[source,properties] +---- +spring.ai.openai-sdk.base-url=https://models.inference.ai.azure.com +spring.ai.openai-sdk.api-key=github_pat_XXXXXXXXXXX +---- + +Or using environment variables: + +[source,bash] +---- +export OPENAI_BASE_URL=https://models.inference.ai.azure.com +export OPENAI_API_KEY=github_pat_XXXXXXXXXXX +---- + +TIP: For enhanced security when handling sensitive information like API keys, you can use Spring Expression Language (SpEL) in your properties: + +[source,properties] +---- +spring.ai.openai-sdk.api-key=${OPENAI_API_KEY} +---- + +=== Add Repositories and BOM + +Spring AI artifacts are published in Maven Central and Spring Snapshot repositories. +Refer to the xref:getting-started.adoc#artifact-repositories[Artifact Repositories] section to add these repositories to your build system. + +To help with dependency management, Spring AI provides a BOM (bill of materials) to ensure that a consistent version of Spring AI is used throughout the entire project. Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build system. + +== Auto-configuration + +Spring AI provides Spring Boot auto-configuration for the OpenAI SDK Embedding Model. +To enable it add the following dependency to your project's Maven `pom.xml` or Gradle `build.gradle` build files: + +[tabs] +====== +Maven:: ++ +[source, xml] +---- + + org.springframework.ai + spring-ai-starter-model-openai-sdk + +---- + +Gradle:: ++ +[source,groovy] +---- +dependencies { + implementation 'org.springframework.ai:spring-ai-starter-model-openai-sdk' +} +---- +====== + +TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file. + +=== Configuration Properties + +==== Connection Properties + +The prefix `spring.ai.openai-sdk` is used as the property prefix that lets you configure the OpenAI SDK client. + +[cols="3,5,1", stripes=even] +|==== +| Property | Description | Default + +| spring.ai.openai-sdk.base-url | The URL to connect to. Auto-detects from `OPENAI_BASE_URL` environment variable if not set. | https://api.openai.com/v1 +| spring.ai.openai-sdk.api-key | The API Key. Auto-detects from `OPENAI_API_KEY` environment variable if not set. | - +| spring.ai.openai-sdk.organization-id | Optionally specify which organization to use for API requests. | - +| spring.ai.openai-sdk.timeout | Request timeout duration. | - +| spring.ai.openai-sdk.max-retries | Maximum number of retry attempts for failed requests. | - +| spring.ai.openai-sdk.proxy | Proxy settings for OpenAI client (Java `Proxy` object). | - +| spring.ai.openai-sdk.custom-headers | Custom HTTP headers to include in requests. Map of header name to header value. | - +|==== + +==== Microsoft Foundry Properties + +The OpenAI SDK implementation provides native support for Microsoft Foundry with automatic configuration: + +[cols="3,5,1", stripes=even] +|==== +| Property | Description | Default + +| spring.ai.openai-sdk.microsoft-foundry | Enable Microsoft Foundry mode. Auto-detected if base URL contains `openai.azure.com`, `cognitiveservices.azure.com`, or `.openai.microsoftFoundry.com`. | false +| spring.ai.openai-sdk.microsoft-deployment-name | Microsoft Foundry deployment name. If not specified, the model name will be used. Also accessible via alias `deployment-name`. | - +| spring.ai.openai-sdk.microsoft-foundry-service-version | Microsoft Foundry API service version. | - +| spring.ai.openai-sdk.credential | Credential object for passwordless authentication (requires `com.azure:azure-identity` dependency). | - +|==== + +TIP: Microsoft Foundry supports passwordless authentication. Add the `com.azure:azure-identity` dependency and the implementation will automatically attempt to use Azure credentials from the environment when no API key is provided. + +==== GitHub Models Properties + +Native support for GitHub Models is available: + +[cols="3,5,1", stripes=even] +|==== +| Property | Description | Default + +| spring.ai.openai-sdk.github-models | Enable GitHub Models mode. Auto-detected if base URL contains `models.github.ai` or `models.inference.ai.azure.com`. | false +|==== + +TIP: GitHub Models requires a Personal Access Token with the `models:read` scope. Set it via the `OPENAI_API_KEY` environment variable or the `spring.ai.openai-sdk.api-key` property. + +==== Embedding Model Properties + +The prefix `spring.ai.openai-sdk.embedding` is the property prefix for configuring the embedding model implementation: + +[cols="3,5,1", stripes=even] +|==== +| Property | Description | Default + +| spring.ai.openai-sdk.embedding.metadata-mode | Document content extraction mode. | EMBED +| spring.ai.openai-sdk.embedding.options.model | The model to use. You can select between models such as: `text-embedding-ada-002`, `text-embedding-3-small`, `text-embedding-3-large`. See the https://platform.openai.com/docs/models[models] page for more information. | `text-embedding-ada-002` +| spring.ai.openai-sdk.embedding.options.user | A unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse. | - +| spring.ai.openai-sdk.embedding.options.dimensions | The number of dimensions the resulting output embeddings should have. Only supported in `text-embedding-3` and later models. | - +|==== + +TIP: All properties prefixed with `spring.ai.openai-sdk.embedding.options` can be overridden at runtime by adding request-specific <> to the `EmbeddingRequest` call. + +== Runtime Options [[embedding-options]] + +The https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/OpenAiSdkEmbeddingOptions.java[OpenAiSdkEmbeddingOptions.java] provides the OpenAI configurations, such as the model to use, dimensions, and user identifier. + +The default options can be configured using the `spring.ai.openai-sdk.embedding.options` properties as well. + +At start-time use the `OpenAiSdkEmbeddingModel` constructor to set the default options used for all embedding requests. +At run-time you can override the default options, using a `OpenAiSdkEmbeddingOptions` instance as part of your `EmbeddingRequest`. + +For example to override the default model name for a specific request: + +[source,java] +---- +EmbeddingResponse embeddingResponse = embeddingModel.call( + new EmbeddingRequest(List.of("Hello World", "World is big and salvation is near"), + OpenAiSdkEmbeddingOptions.builder() + .model("text-embedding-3-large") + .dimensions(1024) + .build())); +---- + +TIP: In addition to the model specific https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/OpenAiSdkEmbeddingOptions.java[OpenAiSdkEmbeddingOptions] you can use a portable link:https://github.com/spring-projects/spring-ai/blob/main/spring-ai-model/src/main/java/org/springframework/ai/embedding/EmbeddingOptions.java[EmbeddingOptions] instance, created with the builder. + +== Sample Controller + +https://start.spring.io/[Create] a new Spring Boot project and add the `spring-ai-openai-sdk` to your pom (or gradle) dependencies. + +Add an `application.properties` file under the `src/main/resources` directory to configure the OpenAI SDK embedding model: + +[source,application.properties] +---- +spring.ai.openai-sdk.api-key=YOUR_API_KEY +spring.ai.openai-sdk.embedding.options.model=text-embedding-ada-002 +---- + +TIP: Replace the `api-key` with your OpenAI credentials. + +This will create an `OpenAiSdkEmbeddingModel` implementation that you can inject into your classes. +Here is an example of a simple `@RestController` class that uses the embedding model. + +[source,java] +---- +@RestController +public class EmbeddingController { + + private final EmbeddingModel embeddingModel; + + @Autowired + public EmbeddingController(EmbeddingModel embeddingModel) { + this.embeddingModel = embeddingModel; + } + + @GetMapping("/ai/embedding") + public Map embed( + @RequestParam(value = "message", defaultValue = "Tell me a joke") String message) { + EmbeddingResponse embeddingResponse = this.embeddingModel.embedForResponse(List.of(message)); + return Map.of("embedding", embeddingResponse); + } +} +---- + +== Manual Configuration + +The https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/OpenAiSdkEmbeddingModel.java[OpenAiSdkEmbeddingModel] implements the `EmbeddingModel` and uses the official OpenAI Java SDK to connect to the OpenAI service. + +If you are not using Spring Boot auto-configuration, you can manually configure the OpenAI SDK Embedding Model. +For this add the `spring-ai-openai-sdk` dependency to your project's Maven `pom.xml` file: + +[source, xml] +---- + + org.springframework.ai + spring-ai-openai-sdk + +---- + +or to your Gradle `build.gradle` build file: + +[source,groovy] +---- +dependencies { + implementation 'org.springframework.ai:spring-ai-openai-sdk' +} +---- + +TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file. + +NOTE: The `spring-ai-openai-sdk` dependency provides access also to the `OpenAiSdkChatModel` and `OpenAiSdkImageModel`. +For more information about the `OpenAiSdkChatModel` refer to the xref:api/chat/openai-sdk-chat.adoc[OpenAI SDK Chat] section. + +Next, create an `OpenAiSdkEmbeddingModel` instance and use it to compute the similarity between two input texts: + +[source,java] +---- +var embeddingOptions = OpenAiSdkEmbeddingOptions.builder() + .model("text-embedding-ada-002") + .apiKey(System.getenv("OPENAI_API_KEY")) + .build(); + +var embeddingModel = new OpenAiSdkEmbeddingModel(embeddingOptions); + +EmbeddingResponse embeddingResponse = embeddingModel + .embedForResponse(List.of("Hello World", "World is big and salvation is near")); +---- + +The `OpenAiSdkEmbeddingOptions` provides the configuration information for the embedding requests. +The options class offers a `builder()` for easy options creation. + +=== Microsoft Foundry Configuration + +For Microsoft Foundry: + +[source,java] +---- +var embeddingOptions = OpenAiSdkEmbeddingOptions.builder() + .baseUrl("https://your-resource.openai.azure.com") + .apiKey(System.getenv("OPENAI_API_KEY")) + .deploymentName("text-embedding-ada-002") + .azureOpenAIServiceVersion(AzureOpenAIServiceVersion.V2024_10_01_PREVIEW) + .azure(true) // Enables Microsoft Foundry mode + .build(); + +var embeddingModel = new OpenAiSdkEmbeddingModel(embeddingOptions); +---- + +TIP: Microsoft Foundry supports passwordless authentication. Add the `com.azure:azure-identity` dependency to your project. If you don't provide an API key, the implementation will automatically attempt to use Azure credentials from your environment. + +=== GitHub Models Configuration + +For GitHub Models: + +[source,java] +---- +var embeddingOptions = OpenAiSdkEmbeddingOptions.builder() + .baseUrl("https://models.inference.ai.azure.com") + .apiKey(System.getenv("GITHUB_TOKEN")) + .model("text-embedding-3-large") + .githubModels(true) + .build(); + +var embeddingModel = new OpenAiSdkEmbeddingModel(embeddingOptions); +---- + +== Observability + +The OpenAI SDK implementation supports Spring AI's observability features through Micrometer. +All embedding model operations are instrumented for monitoring and tracing. + +== Additional Resources + +* link:https://github.com/openai/openai-java[Official OpenAI Java SDK] +* link:https://platform.openai.com/docs/api-reference/embeddings[OpenAI Embeddings API Documentation] +* link:https://platform.openai.com/docs/models[OpenAI Models] +* link:https://learn.microsoft.com/en-us/azure/ai-foundry/[Microsoft Foundry Documentation] +* link:https://github.com/marketplace/models[GitHub Models] diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/image/openai-sdk-image.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/image/openai-sdk-image.adoc new file mode 100644 index 00000000000..ad7409d651b --- /dev/null +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/image/openai-sdk-image.adoc @@ -0,0 +1,381 @@ += OpenAI SDK Image Generation (Official) + +Spring AI supports OpenAI's DALL-E image generation models through the OpenAI Java SDK, providing a robust and officially-maintained integration with OpenAI's services including Microsoft Foundry and GitHub Models. + +NOTE: This implementation uses the official link:https://github.com/openai/openai-java[OpenAI Java SDK] from OpenAI. For the alternative Spring AI implementation, see xref:api/image/openai-image.adoc[OpenAI Image Generation]. + +DALL-E is a state-of-the-art image generation model from OpenAI that can create realistic images and art from natural language descriptions. + +The OpenAI SDK module automatically detects the service provider (OpenAI, Microsoft Foundry, or GitHub Models) based on the base URL you provide. + +== Authentication + +Authentication is done using a base URL and an API Key. The implementation provides flexible configuration options through Spring Boot properties or environment variables. + +=== Using OpenAI + +If you are using OpenAI directly, create an account at https://platform.openai.com/signup[OpenAI signup page] and generate an API key on the https://platform.openai.com/account/api-keys[API Keys page]. + +The base URL doesn't need to be set as it defaults to `https://api.openai.com/v1`: + +[source,properties] +---- +spring.ai.openai-sdk.api-key= +# base-url is optional, defaults to https://api.openai.com/v1 +---- + +Or using environment variables: + +[source,bash] +---- +export OPENAI_API_KEY= +# OPENAI_BASE_URL is optional, defaults to https://api.openai.com/v1 +---- + +=== Using Microsoft Foundry + +Microsoft Foundry is automatically detected when using a Microsoft Foundry URL. You can configure it using properties: + +[source,properties] +---- +spring.ai.openai-sdk.base-url=https://.openai.azure.com +spring.ai.openai-sdk.api-key= +spring.ai.openai-sdk.microsoft-deployment-name= +---- + +Or using environment variables: + +[source,bash] +---- +export OPENAI_BASE_URL=https://.openai.azure.com +export OPENAI_API_KEY= +---- + +**Passwordless Authentication (Recommended for Azure):** + +Microsoft Foundry supports passwordless authentication without providing an API key, which is more secure when running on Azure. + +To enable passwordless authentication, add the `com.azure:azure-identity` dependency: + +[source,xml] +---- + + com.azure + azure-identity + +---- + +Then configure without an API key: + +[source,properties] +---- +spring.ai.openai-sdk.base-url=https://.openai.azure.com +spring.ai.openai-sdk.microsoft-deployment-name= +# No api-key needed - will use Azure credentials from environment +---- + +=== Using GitHub Models + +GitHub Models is automatically detected when using the GitHub Models base URL. You'll need to create a GitHub Personal Access Token (PAT) with the `models:read` scope. + +[source,properties] +---- +spring.ai.openai-sdk.base-url=https://models.inference.ai.azure.com +spring.ai.openai-sdk.api-key=github_pat_XXXXXXXXXXX +---- + +Or using environment variables: + +[source,bash] +---- +export OPENAI_BASE_URL=https://models.inference.ai.azure.com +export OPENAI_API_KEY=github_pat_XXXXXXXXXXX +---- + +TIP: For enhanced security when handling sensitive information like API keys, you can use Spring Expression Language (SpEL) in your properties: + +[source,properties] +---- +spring.ai.openai-sdk.api-key=${OPENAI_API_KEY} +---- + +=== Add Repositories and BOM + +Spring AI artifacts are published in Maven Central and Spring Snapshot repositories. +Refer to the xref:getting-started.adoc#artifact-repositories[Artifact Repositories] section to add these repositories to your build system. + +To help with dependency management, Spring AI provides a BOM (bill of materials) to ensure that a consistent version of Spring AI is used throughout the entire project. Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build system. + +== Auto-configuration + +Spring AI provides Spring Boot auto-configuration for the OpenAI SDK Image Model. +To enable it add the following dependency to your project's Maven `pom.xml` or Gradle `build.gradle` build files: + +[tabs] +====== +Maven:: ++ +[source, xml] +---- + + org.springframework.ai + spring-ai-starter-model-openai-sdk + +---- + +Gradle:: ++ +[source,groovy] +---- +dependencies { + implementation 'org.springframework.ai:spring-ai-starter-model-openai-sdk' +} +---- +====== + +TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file. + +=== Configuration Properties + +==== Connection Properties + +The prefix `spring.ai.openai-sdk` is used as the property prefix that lets you configure the OpenAI SDK client. + +[cols="3,5,1", stripes=even] +|==== +| Property | Description | Default + +| spring.ai.openai-sdk.base-url | The URL to connect to. Auto-detects from `OPENAI_BASE_URL` environment variable if not set. | https://api.openai.com/v1 +| spring.ai.openai-sdk.api-key | The API Key. Auto-detects from `OPENAI_API_KEY` environment variable if not set. | - +| spring.ai.openai-sdk.organization-id | Optionally specify which organization to use for API requests. | - +| spring.ai.openai-sdk.timeout | Request timeout duration. | - +| spring.ai.openai-sdk.max-retries | Maximum number of retry attempts for failed requests. | - +| spring.ai.openai-sdk.proxy | Proxy settings for OpenAI client (Java `Proxy` object). | - +| spring.ai.openai-sdk.custom-headers | Custom HTTP headers to include in requests. Map of header name to header value. | - +|==== + +==== Microsoft Foundry Properties + +The OpenAI SDK implementation provides native support for Microsoft Foundry with automatic configuration: + +[cols="3,5,1", stripes=even] +|==== +| Property | Description | Default + +| spring.ai.openai-sdk.microsoft-foundry | Enable Microsoft Foundry mode. Auto-detected if base URL contains `openai.azure.com`, `cognitiveservices.azure.com`, or `.openai.microsoftFoundry.com`. | false +| spring.ai.openai-sdk.microsoft-deployment-name | Microsoft Foundry deployment name. If not specified, the model name will be used. Also accessible via alias `deployment-name`. | - +| spring.ai.openai-sdk.microsoft-foundry-service-version | Microsoft Foundry API service version. | - +| spring.ai.openai-sdk.credential | Credential object for passwordless authentication (requires `com.azure:azure-identity` dependency). | - +|==== + +TIP: Microsoft Foundry supports passwordless authentication. Add the `com.azure:azure-identity` dependency and the implementation will automatically attempt to use Azure credentials from the environment when no API key is provided. + +==== GitHub Models Properties + +Native support for GitHub Models is available: + +[cols="3,5,1", stripes=even] +|==== +| Property | Description | Default + +| spring.ai.openai-sdk.github-models | Enable GitHub Models mode. Auto-detected if base URL contains `models.github.ai` or `models.inference.ai.azure.com`. | false +|==== + +TIP: GitHub Models requires a Personal Access Token with the `models:read` scope. Set it via the `OPENAI_API_KEY` environment variable or the `spring.ai.openai-sdk.api-key` property. + +==== Image Model Properties + +The prefix `spring.ai.openai-sdk.image` is the property prefix for configuring the image model implementation: + +[cols="3,5,1", stripes=even] +|==== +| Property | Description | Default + +| spring.ai.openai-sdk.image.options.model | The model to use for image generation. Available models: `dall-e-2`, `dall-e-3`. See the https://platform.openai.com/docs/models[models] page for more information. | `dall-e-3` +| spring.ai.openai-sdk.image.options.n | The number of images to generate. Must be between 1 and 10. For `dall-e-3`, only n=1 is supported. | - +| spring.ai.openai-sdk.image.options.quality | The quality of the image that will be generated. `hd` creates images with finer details and greater consistency across the image. This parameter is only supported for `dall-e-3`. Available values: `standard`, `hd`. | - +| spring.ai.openai-sdk.image.options.response-format | The format in which the generated images are returned. Must be one of `url` or `b64_json`. | - +| spring.ai.openai-sdk.image.options.size | The size of the generated images. Must be one of `256x256`, `512x512`, or `1024x1024` for `dall-e-2`. Must be one of `1024x1024`, `1792x1024`, or `1024x1792` for `dall-e-3` models. | - +| spring.ai.openai-sdk.image.options.width | The width of the generated images. Must be one of 256, 512, or 1024 for `dall-e-2`. | - +| spring.ai.openai-sdk.image.options.height | The height of the generated images. Must be one of 256, 512, or 1024 for `dall-e-2`. | - +| spring.ai.openai-sdk.image.options.style | The style of the generated images. Must be one of `vivid` or `natural`. Vivid causes the model to lean towards generating hyper-real and dramatic images. Natural causes the model to produce more natural, less hyper-real looking images. This parameter is only supported for `dall-e-3`. | - +| spring.ai.openai-sdk.image.options.user | A unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse. | - +|==== + +TIP: All properties prefixed with `spring.ai.openai-sdk.image.options` can be overridden at runtime by adding request-specific <> to the `ImagePrompt` call. + +== Runtime Options [[image-options]] + +The https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/OpenAiSdkImageOptions.java[OpenAiSdkImageOptions.java] provides the OpenAI configurations, such as the model to use, quality, size, style, and number of images to generate. + +The default options can be configured using the `spring.ai.openai-sdk.image.options` properties as well. + +At start-time use the `OpenAiSdkImageModel` constructor to set the default options used for all image generation requests. +At run-time you can override the default options, using a `OpenAiSdkImageOptions` instance as part of your `ImagePrompt`. + +For example to override the default model and quality for a specific request: + +[source,java] +---- +ImageResponse response = imageModel.call( + new ImagePrompt("A light cream colored mini golden doodle", + OpenAiSdkImageOptions.builder() + .model("dall-e-3") + .quality("hd") + .N(1) + .width(1024) + .height(1024) + .style("vivid") + .build())); +---- + +TIP: In addition to the model specific https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/OpenAiSdkImageOptions.java[OpenAiSdkImageOptions] you can use a portable link:https://github.com/spring-projects/spring-ai/blob/main/spring-ai-model/src/main/java/org/springframework/ai/image/ImageOptions.java[ImageOptions] instance, created with the link:https://github.com/spring-projects/spring-ai/blob/main/spring-ai-model/src/main/java/org/springframework/ai/image/ImageOptionsBuilder.java[ImageOptionsBuilder#builder()]. + +== Sample Controller + +https://start.spring.io/[Create] a new Spring Boot project and add the `spring-ai-openai-sdk` to your pom (or gradle) dependencies. + +Add an `application.properties` file under the `src/main/resources` directory to configure the OpenAI SDK image model: + +[source,application.properties] +---- +spring.ai.openai-sdk.api-key=YOUR_API_KEY +spring.ai.openai-sdk.image.options.model=dall-e-3 +---- + +TIP: Replace the `api-key` with your OpenAI credentials. + +This will create an `OpenAiSdkImageModel` implementation that you can inject into your classes. +Here is an example of a simple `@RestController` class that uses the image model. + +[source,java] +---- +@RestController +public class ImageController { + + private final ImageModel imageModel; + + @Autowired + public ImageController(ImageModel imageModel) { + this.imageModel = imageModel; + } + + @GetMapping("/ai/image") + public Map generateImage( + @RequestParam(value = "prompt", defaultValue = "A light cream colored mini golden doodle") String prompt) { + ImageResponse response = this.imageModel.call( + new ImagePrompt(prompt, + OpenAiSdkImageOptions.builder() + .quality("hd") + .N(1) + .width(1024) + .height(1024) + .build())); + + String imageUrl = response.getResult().getOutput().getUrl(); + return Map.of("url", imageUrl); + } +} +---- + +== Manual Configuration + +The https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-openai-sdk/src/main/java/org/springframework/ai/openaisdk/OpenAiSdkImageModel.java[OpenAiSdkImageModel] implements the `ImageModel` and uses the official OpenAI Java SDK to connect to the OpenAI service. + +If you are not using Spring Boot auto-configuration, you can manually configure the OpenAI SDK Image Model. +For this add the `spring-ai-openai-sdk` dependency to your project's Maven `pom.xml` file: + +[source, xml] +---- + + org.springframework.ai + spring-ai-openai-sdk + +---- + +or to your Gradle `build.gradle` build file: + +[source,groovy] +---- +dependencies { + implementation 'org.springframework.ai:spring-ai-openai-sdk' +} +---- + +TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file. + +NOTE: The `spring-ai-openai-sdk` dependency provides access also to the `OpenAiSdkChatModel` and `OpenAiSdkEmbeddingModel`. +For more information about the `OpenAiSdkChatModel` refer to the xref:api/chat/openai-sdk-chat.adoc[OpenAI SDK Chat] section. + +Next, create an `OpenAiSdkImageModel` instance and use it to generate images: + +[source,java] +---- +var imageOptions = OpenAiSdkImageOptions.builder() + .model("dall-e-3") + .quality("hd") + .apiKey(System.getenv("OPENAI_API_KEY")) + .build(); + +var imageModel = new OpenAiSdkImageModel(imageOptions); + +ImageResponse response = imageModel.call( + new ImagePrompt("A light cream colored mini golden doodle", + OpenAiSdkImageOptions.builder() + .N(1) + .width(1024) + .height(1024) + .build())); +---- + +The `OpenAiSdkImageOptions` provides the configuration information for the image generation requests. +The options class offers a `builder()` for easy options creation. + +=== Microsoft Foundry Configuration + +For Microsoft Foundry: + +[source,java] +---- +var imageOptions = OpenAiSdkImageOptions.builder() + .baseUrl("https://your-resource.openai.azure.com") + .apiKey(System.getenv("OPENAI_API_KEY")) + .deploymentName("dall-e-3") + .azureOpenAIServiceVersion(AzureOpenAIServiceVersion.V2024_10_01_PREVIEW) + .azure(true) // Enables Microsoft Foundry mode + .build(); + +var imageModel = new OpenAiSdkImageModel(imageOptions); +---- + +TIP: Microsoft Foundry supports passwordless authentication. Add the `com.azure:azure-identity` dependency to your project. If you don't provide an API key, the implementation will automatically attempt to use Azure credentials from your environment. + +=== GitHub Models Configuration + +For GitHub Models: + +[source,java] +---- +var imageOptions = OpenAiSdkImageOptions.builder() + .baseUrl("https://models.inference.ai.azure.com") + .apiKey(System.getenv("GITHUB_TOKEN")) + .model("dall-e-3") + .githubModels(true) + .build(); + +var imageModel = new OpenAiSdkImageModel(imageOptions); +---- + +== Observability + +The OpenAI SDK implementation supports Spring AI's observability features through Micrometer. +All image model operations are instrumented for monitoring and tracing. + +== Additional Resources + +* link:https://github.com/openai/openai-java[Official OpenAI Java SDK] +* link:https://platform.openai.com/docs/api-reference/images[OpenAI Images API Documentation] +* link:https://platform.openai.com/docs/guides/images[OpenAI Image Generation Guide] +* link:https://platform.openai.com/docs/models[OpenAI Models] +* link:https://learn.microsoft.com/en-us/azure/ai-foundry/[Microsoft Foundry Documentation] +* link:https://github.com/marketplace/models[GitHub Models] diff --git a/spring-ai-model/src/main/java/org/springframework/ai/model/SpringAIModels.java b/spring-ai-model/src/main/java/org/springframework/ai/model/SpringAIModels.java index 19f8a8a3258..eb57d9a44a3 100644 --- a/spring-ai-model/src/main/java/org/springframework/ai/model/SpringAIModels.java +++ b/spring-ai-model/src/main/java/org/springframework/ai/model/SpringAIModels.java @@ -44,6 +44,8 @@ private SpringAIModels() { public static final String OPENAI = "openai"; + public static final String OPENAI_SDK = "openai-sdk"; + public static final String POSTGRESML = "postgresml"; public static final String STABILITY_AI = "stabilityai"; diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-model-openai-sdk/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-model-openai-sdk/pom.xml new file mode 100644 index 00000000000..cb94ce4b0fb --- /dev/null +++ b/spring-ai-spring-boot-starters/spring-ai-starter-model-openai-sdk/pom.xml @@ -0,0 +1,70 @@ + + + + + 4.0.0 + + org.springframework.ai + spring-ai-parent + 2.0.0-SNAPSHOT + ../../pom.xml + + spring-ai-starter-model-openai-sdk + jar + Spring AI Starter - OpenAI SDK + Spring AI Open AI SDK Spring Boot Starter + https://github.com/spring-projects/spring-ai + + + https://github.com/spring-projects/spring-ai + git://github.com/spring-projects/spring-ai.git + git@github.com:spring-projects/spring-ai.git + + + + + + org.springframework.boot + spring-boot-starter + + + + org.springframework.ai + spring-ai-autoconfigure-model-openai-sdk + ${project.parent.version} + + + + org.springframework.ai + spring-ai-openai-sdk + ${project.parent.version} + + + + org.springframework.ai + spring-ai-autoconfigure-model-chat-client + ${project.parent.version} + + + + org.springframework.ai + spring-ai-autoconfigure-model-chat-memory + ${project.parent.version} + + + + diff --git a/src/checkstyle/checkstyle-suppressions.xml b/src/checkstyle/checkstyle-suppressions.xml index a6ba36d1ee5..38a8d472778 100644 --- a/src/checkstyle/checkstyle-suppressions.xml +++ b/src/checkstyle/checkstyle-suppressions.xml @@ -41,6 +41,7 @@ +