From efe629a4d7f00e011e7cd1a272602d8f42f335fd Mon Sep 17 00:00:00 2001 From: Rene Maierhofer Date: Sat, 1 Nov 2025 14:23:22 +0100 Subject: [PATCH] Implements ReplicateApi Signed-off-by: renemrhfr --- .../pom.xml | 91 ++++ .../ReplicateChatAutoConfiguration.java | 122 +++++ .../ReplicateChatProperties.java | 45 ++ .../ReplicateConnectionProperties.java | 54 +++ .../ReplicateMediaProperties.java | 46 ++ .../ReplicateStringProperties.java | 46 ++ .../ReplicateStructuredProperties.java | 46 ++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../ReplicateChatAutoConfigurationIT.java | 140 ++++++ .../ReplicatePropertiesTests.java | 138 ++++++ models/spring-ai-replicate/README.md | 4 + models/spring-ai-replicate/pom.xml | 85 ++++ .../ai/replicate/ReplicateChatModel.java | 313 +++++++++++++ .../ai/replicate/ReplicateChatOptions.java | 240 ++++++++++ .../ai/replicate/ReplicateMediaModel.java | 208 +++++++++ .../ai/replicate/ReplicateOptions.java | 207 +++++++++ .../ai/replicate/ReplicateOptionsUtils.java | 84 ++++ .../ai/replicate/ReplicateStringModel.java | 174 +++++++ .../replicate/ReplicateStructuredModel.java | 220 +++++++++ .../ai/replicate/api/ReplicateApi.java | 424 ++++++++++++++++++ .../ai/replicate/ReplicateChatModelIT.java | 131 ++++++ .../replicate/ReplicateChatOptionsTests.java | 123 +++++ .../ai/replicate/ReplicateMediaModelIT.java | 73 +++ .../ai/replicate/ReplicateOptionsTests.java | 123 +++++ .../replicate/ReplicateOptionsUtilsTests.java | 89 ++++ .../ai/replicate/ReplicateStringModelIT.java | 102 +++++ .../replicate/ReplicateStructuredModelIT.java | 73 +++ .../replicate/ReplicateTestConfiguration.java | 92 ++++ .../api/ReplicateApiBuilderTests.java | 84 ++++ .../src/test/resources/test-image.jpg | Bin 0 -> 95670 bytes pom.xml | 2 + .../observation/conventions/AiProvider.java | 5 + 32 files changed, 3585 insertions(+) create mode 100644 auto-configurations/models/spring-ai-autoconfigure-model-replicate/pom.xml create mode 100644 auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/main/java/org/springframework/ai/model/replicate/autoconfigure/ReplicateChatAutoConfiguration.java create mode 100644 auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/main/java/org/springframework/ai/model/replicate/autoconfigure/ReplicateChatProperties.java create mode 100644 auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/main/java/org/springframework/ai/model/replicate/autoconfigure/ReplicateConnectionProperties.java create mode 100644 auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/main/java/org/springframework/ai/model/replicate/autoconfigure/ReplicateMediaProperties.java create mode 100644 auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/main/java/org/springframework/ai/model/replicate/autoconfigure/ReplicateStringProperties.java create mode 100644 auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/main/java/org/springframework/ai/model/replicate/autoconfigure/ReplicateStructuredProperties.java create mode 100644 auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/test/java/org/springframework/ai/model/replicate/autoconfigure/ReplicateChatAutoConfigurationIT.java create mode 100644 auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/test/java/org/springframework/ai/model/replicate/autoconfigure/ReplicatePropertiesTests.java create mode 100644 models/spring-ai-replicate/README.md create mode 100644 models/spring-ai-replicate/pom.xml create mode 100644 models/spring-ai-replicate/src/main/java/org/springframework/ai/replicate/ReplicateChatModel.java create mode 100644 models/spring-ai-replicate/src/main/java/org/springframework/ai/replicate/ReplicateChatOptions.java create mode 100644 models/spring-ai-replicate/src/main/java/org/springframework/ai/replicate/ReplicateMediaModel.java create mode 100644 models/spring-ai-replicate/src/main/java/org/springframework/ai/replicate/ReplicateOptions.java create mode 100644 models/spring-ai-replicate/src/main/java/org/springframework/ai/replicate/ReplicateOptionsUtils.java create mode 100644 models/spring-ai-replicate/src/main/java/org/springframework/ai/replicate/ReplicateStringModel.java create mode 100644 models/spring-ai-replicate/src/main/java/org/springframework/ai/replicate/ReplicateStructuredModel.java create mode 100644 models/spring-ai-replicate/src/main/java/org/springframework/ai/replicate/api/ReplicateApi.java create mode 100644 models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/ReplicateChatModelIT.java create mode 100644 models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/ReplicateChatOptionsTests.java create mode 100644 models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/ReplicateMediaModelIT.java create mode 100644 models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/ReplicateOptionsTests.java create mode 100644 models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/ReplicateOptionsUtilsTests.java create mode 100644 models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/ReplicateStringModelIT.java create mode 100644 models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/ReplicateStructuredModelIT.java create mode 100644 models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/ReplicateTestConfiguration.java create mode 100644 models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/api/ReplicateApiBuilderTests.java create mode 100644 models/spring-ai-replicate/src/test/resources/test-image.jpg diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-replicate/pom.xml b/auto-configurations/models/spring-ai-autoconfigure-model-replicate/pom.xml new file mode 100644 index 00000000000..cd285955d59 --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-replicate/pom.xml @@ -0,0 +1,91 @@ + + + + 4.0.0 + + org.springframework.ai + spring-ai-parent + 1.1.0-SNAPSHOT + ../../../pom.xml + + spring-ai-autoconfigure-model-replicate + jar + Spring AI Replicate Auto Configuration + Spring AI Replicate 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-replicate + ${project.parent.version} + + + + + + org.springframework.ai + spring-ai-autoconfigure-retry + ${project.parent.version} + + + + + org.springframework.boot + spring-boot-starter + true + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + org.springframework.boot + spring-boot-autoconfigure-processor + true + + + + + org.springframework.ai + spring-ai-test + ${project.parent.version} + test + + + + org.springframework.boot + spring-boot-starter-test + test + + + + diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/main/java/org/springframework/ai/model/replicate/autoconfigure/ReplicateChatAutoConfiguration.java b/auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/main/java/org/springframework/ai/model/replicate/autoconfigure/ReplicateChatAutoConfiguration.java new file mode 100644 index 00000000000..25a1d7e652f --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/main/java/org/springframework/ai/model/replicate/autoconfigure/ReplicateChatAutoConfiguration.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.model.replicate.autoconfigure; + +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.ai.replicate.ReplicateChatModel; +import org.springframework.ai.replicate.ReplicateMediaModel; +import org.springframework.ai.replicate.ReplicateStringModel; +import org.springframework.ai.replicate.ReplicateStructuredModel; +import org.springframework.ai.replicate.api.ReplicateApi; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.util.StringUtils; +import org.springframework.web.client.ResponseErrorHandler; +import org.springframework.web.client.RestClient; + +/** + * {@link AutoConfiguration Auto-configuration} for Replicate models. + * + * @author Rene Maierhofer + * @since 1.1.0 + */ +@AutoConfiguration(after = RestClientAutoConfiguration.class) +@ConditionalOnClass(ReplicateApi.class) +@EnableConfigurationProperties({ ReplicateConnectionProperties.class, ReplicateChatProperties.class, + ReplicateMediaProperties.class, ReplicateStringProperties.class, ReplicateStructuredProperties.class }) +@ConditionalOnProperty(prefix = ReplicateConnectionProperties.CONFIG_PREFIX, name = "api-token") +public class ReplicateChatAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = ReplicateConnectionProperties.CONFIG_PREFIX, name = "api-token") + public ReplicateApi replicateApi(ReplicateConnectionProperties connectionProperties, + ObjectProvider restClientBuilderProvider, + ObjectProvider responseErrorHandlerProvider) { + + if (!StringUtils.hasText(connectionProperties.getApiToken())) { + throw new IllegalArgumentException( + "Replicate API token must be configured via spring.ai.replicate.api-token"); + } + + var builder = ReplicateApi.builder() + .apiKey(connectionProperties.getApiToken()) + .baseUrl(connectionProperties.getBaseUrl()); + + RestClient.Builder restClientBuilder = restClientBuilderProvider.getIfAvailable(RestClient::builder); + if (restClientBuilder != null) { + builder.restClientBuilder(restClientBuilder); + } + + ResponseErrorHandler errorHandler = responseErrorHandlerProvider.getIfAvailable(); + if (errorHandler != null) { + builder.responseErrorHandler(errorHandler); + } + + return builder.build(); + } + + @Bean + @ConditionalOnMissingBean + public ReplicateChatModel replicateChatModel(ReplicateApi replicateApi, ReplicateChatProperties chatProperties, + ObjectProvider observationRegistry) { + return ReplicateChatModel.builder() + .replicateApi(replicateApi) + .observationRegistry(observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)) + .defaultOptions(chatProperties.getOptions()) + .build(); + } + + @Bean + @ConditionalOnMissingBean + public ReplicateMediaModel replicateMediaModel(ReplicateApi replicateApi, + ReplicateMediaProperties mediaProperties) { + return ReplicateMediaModel.builder() + .replicateApi(replicateApi) + .defaultOptions(mediaProperties.getOptions()) + .build(); + } + + @Bean + @ConditionalOnMissingBean + public ReplicateStringModel replicateStringModel(ReplicateApi replicateApi, + ReplicateStringProperties stringProperties) { + return ReplicateStringModel.builder() + .replicateApi(replicateApi) + .defaultOptions(stringProperties.getOptions()) + .build(); + } + + @Bean + @ConditionalOnMissingBean + public ReplicateStructuredModel replicateStructuredModel(ReplicateApi replicateApi, + ReplicateStructuredProperties structuredProperties) { + + return ReplicateStructuredModel.builder() + .replicateApi(replicateApi) + .defaultOptions(structuredProperties.getOptions()) + .build(); + } + +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/main/java/org/springframework/ai/model/replicate/autoconfigure/ReplicateChatProperties.java b/auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/main/java/org/springframework/ai/model/replicate/autoconfigure/ReplicateChatProperties.java new file mode 100644 index 00000000000..3acccf2be4e --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/main/java/org/springframework/ai/model/replicate/autoconfigure/ReplicateChatProperties.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.model.replicate.autoconfigure; + +import org.springframework.ai.replicate.ReplicateChatOptions; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * Chat properties for Replicate AI. + * + * @author Rene Maierhofer + * @since 1.1.0 + */ +@ConfigurationProperties(ReplicateChatProperties.CONFIG_PREFIX) +public class ReplicateChatProperties { + + public static final String CONFIG_PREFIX = "spring.ai.replicate.chat"; + + @NestedConfigurationProperty + private ReplicateChatOptions options = ReplicateChatOptions.builder().build(); + + public ReplicateChatOptions getOptions() { + return this.options; + } + + public void setOptions(ReplicateChatOptions options) { + this.options = options; + } + +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/main/java/org/springframework/ai/model/replicate/autoconfigure/ReplicateConnectionProperties.java b/auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/main/java/org/springframework/ai/model/replicate/autoconfigure/ReplicateConnectionProperties.java new file mode 100644 index 00000000000..deaed7914d8 --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/main/java/org/springframework/ai/model/replicate/autoconfigure/ReplicateConnectionProperties.java @@ -0,0 +1,54 @@ +/* + * 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.replicate.autoconfigure; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Connection properties for Replicate AI. + * + * @author Rene Maierhofer + * @since 1.1.0 + */ +@ConfigurationProperties(ReplicateConnectionProperties.CONFIG_PREFIX) +public class ReplicateConnectionProperties { + + public static final String CONFIG_PREFIX = "spring.ai.replicate"; + + public static final String DEFAULT_BASE_URL = "https://api.replicate.com/v1"; + + private String apiToken; + + private String baseUrl = DEFAULT_BASE_URL; + + public String getApiToken() { + return this.apiToken; + } + + public void setApiToken(String apiToken) { + this.apiToken = apiToken; + } + + public String getBaseUrl() { + return this.baseUrl; + } + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/main/java/org/springframework/ai/model/replicate/autoconfigure/ReplicateMediaProperties.java b/auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/main/java/org/springframework/ai/model/replicate/autoconfigure/ReplicateMediaProperties.java new file mode 100644 index 00000000000..4a606428401 --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/main/java/org/springframework/ai/model/replicate/autoconfigure/ReplicateMediaProperties.java @@ -0,0 +1,46 @@ +/* + * 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.replicate.autoconfigure; + +import org.springframework.ai.replicate.ReplicateOptions; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * Media model properties for Replicate AI. Used for image, video, and audio generation + * models. + * + * @author Rene Maierhofer + * @since 1.1.0 + */ +@ConfigurationProperties(ReplicateMediaProperties.CONFIG_PREFIX) +public class ReplicateMediaProperties { + + public static final String CONFIG_PREFIX = "spring.ai.replicate.media"; + + @NestedConfigurationProperty + private ReplicateOptions options = ReplicateOptions.builder().build(); + + public ReplicateOptions getOptions() { + return this.options; + } + + public void setOptions(ReplicateOptions options) { + this.options = options; + } + +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/main/java/org/springframework/ai/model/replicate/autoconfigure/ReplicateStringProperties.java b/auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/main/java/org/springframework/ai/model/replicate/autoconfigure/ReplicateStringProperties.java new file mode 100644 index 00000000000..416ee1f816f --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/main/java/org/springframework/ai/model/replicate/autoconfigure/ReplicateStringProperties.java @@ -0,0 +1,46 @@ +/* + * 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.replicate.autoconfigure; + +import org.springframework.ai.replicate.ReplicateOptions; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * String model properties for Replicate AI. Used for models that return simple string + * outputs like classifiers and filters. + * + * @author Rene Maierhofer + * @since 1.1.0 + */ +@ConfigurationProperties(ReplicateStringProperties.CONFIG_PREFIX) +public class ReplicateStringProperties { + + public static final String CONFIG_PREFIX = "spring.ai.replicate.string"; + + @NestedConfigurationProperty + private ReplicateOptions options = ReplicateOptions.builder().build(); + + public ReplicateOptions getOptions() { + return this.options; + } + + public void setOptions(ReplicateOptions options) { + this.options = options; + } + +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/main/java/org/springframework/ai/model/replicate/autoconfigure/ReplicateStructuredProperties.java b/auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/main/java/org/springframework/ai/model/replicate/autoconfigure/ReplicateStructuredProperties.java new file mode 100644 index 00000000000..8eb3f84df6f --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/main/java/org/springframework/ai/model/replicate/autoconfigure/ReplicateStructuredProperties.java @@ -0,0 +1,46 @@ +/* + * 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.replicate.autoconfigure; + +import org.springframework.ai.replicate.ReplicateOptions; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * Structured model properties for Replicate AI. Used for models that return structured + * JSON objects with multiple fields. + * + * @author Rene Maierhofer + * @since 1.1.0 + */ +@ConfigurationProperties(ReplicateStructuredProperties.CONFIG_PREFIX) +public class ReplicateStructuredProperties { + + public static final String CONFIG_PREFIX = "spring.ai.replicate.structured"; + + @NestedConfigurationProperty + private ReplicateOptions options = ReplicateOptions.builder().build(); + + public ReplicateOptions getOptions() { + return this.options; + } + + public void setOptions(ReplicateOptions options) { + this.options = options; + } + +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..bde6423d44e --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.springframework.ai.model.replicate.autoconfigure.ReplicateChatAutoConfiguration diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/test/java/org/springframework/ai/model/replicate/autoconfigure/ReplicateChatAutoConfigurationIT.java b/auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/test/java/org/springframework/ai/model/replicate/autoconfigure/ReplicateChatAutoConfigurationIT.java new file mode 100644 index 00000000000..2c25003f811 --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/test/java/org/springframework/ai/model/replicate/autoconfigure/ReplicateChatAutoConfigurationIT.java @@ -0,0 +1,140 @@ +/* + * 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.replicate.autoconfigure; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import org.springframework.ai.chat.metadata.ChatResponseMetadata; +import org.springframework.ai.chat.metadata.Usage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.replicate.ReplicateChatModel; +import org.springframework.ai.replicate.ReplicateMediaModel; +import org.springframework.ai.replicate.ReplicateStringModel; +import org.springframework.ai.replicate.ReplicateStructuredModel; +import org.springframework.ai.replicate.api.ReplicateApi; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link ReplicateChatAutoConfiguration}. + * + * @author Rene Maierhofer + */ +@EnabledIfEnvironmentVariable(named = "REPLICATE_API_TOKEN", matches = ".+") +class ReplicateChatAutoConfigurationIT { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.replicate.api-token=" + System.getenv("REPLICATE_API_TOKEN")) + .withConfiguration( + AutoConfigurations.of(RestClientAutoConfiguration.class, ReplicateChatAutoConfiguration.class)); + + @Test + void testReplicateApiBean() { + this.contextRunner.run(context -> { + assertThat(context).hasSingleBean(ReplicateApi.class); + ReplicateApi api = context.getBean(ReplicateApi.class); + assertThat(api).isNotNull(); + }); + } + + @Test + void testReplicateChatModelBean() { + this.contextRunner.withPropertyValues("spring.ai.replicate.chat.options.model=meta/meta-llama-3-8b-instruct") + .run(context -> { + assertThat(context).hasSingleBean(ReplicateChatModel.class); + ReplicateChatModel chatModel = context.getBean(ReplicateChatModel.class); + assertThat(chatModel).isNotNull(); + + String response = chatModel.call("Say hello"); + assertThat(response).isNotEmpty(); + }); + } + + @Test + void testReplicateMediaModelBean() { + this.contextRunner.withPropertyValues("spring.ai.replicate.media.options.model=black-forest-labs/flux-schnell") + .run(context -> { + assertThat(context).hasSingleBean(ReplicateMediaModel.class); + ReplicateMediaModel mediaModel = context.getBean(ReplicateMediaModel.class); + assertThat(mediaModel).isNotNull(); + }); + } + + @Test + void testReplicateStringModelBean() { + this.contextRunner + .withPropertyValues("spring.ai.replicate.string.options.model=falcons-ai/nsfw_image_detection") + .run(context -> { + assertThat(context).hasSingleBean(ReplicateStringModel.class); + ReplicateStringModel stringModel = context.getBean(ReplicateStringModel.class); + assertThat(stringModel).isNotNull(); + }); + } + + @Test + void testReplicateStructuredModelBean() { + this.contextRunner.withPropertyValues("spring.ai.replicate.structured.options.model=openai/clip") + .run(context -> { + assertThat(context).hasSingleBean(ReplicateStructuredModel.class); + ReplicateStructuredModel structuredModel = context.getBean(ReplicateStructuredModel.class); + assertThat(structuredModel).isNotNull(); + }); + } + + @Test + void testAllModelBeansCreated() { + this.contextRunner + .withPropertyValues("spring.ai.replicate.chat.options.model=meta/meta-llama-3-8b-instruct", + "spring.ai.replicate.media.options.model=black-forest-labs/flux-schnell", + "spring.ai.replicate.string.options.model=falcons-ai/nsfw_image_detection", + "spring.ai.replicate.structured.options.model=openai/clip") + .run(context -> { + assertThat(context).hasSingleBean(ReplicateApi.class); + assertThat(context).hasSingleBean(ReplicateChatModel.class); + assertThat(context).hasSingleBean(ReplicateMediaModel.class); + assertThat(context).hasSingleBean(ReplicateStringModel.class); + assertThat(context).hasSingleBean(ReplicateStructuredModel.class); + }); + } + + @Test + void testChatInputParameters() { + this.contextRunner + .withPropertyValues("spring.ai.replicate.chat.options.model=meta/meta-llama-3-8b-instruct", + "spring.ai.replicate.chat.options.input.temperature=0.7", + "spring.ai.replicate.chat.options.input.max_tokens=50") + .run(context -> { + ReplicateChatModel chatModel = context.getBean(ReplicateChatModel.class); + assertThat(chatModel).isNotNull(); + Prompt prompt = new Prompt("Write a very long poem."); + ChatResponse response = chatModel.call(prompt); + + assertThat(response).isNotNull(); + assertThat(response.getResults()).isNotEmpty(); + assertThat(response.getResult().getOutput().getText()).isNotEmpty(); + ChatResponseMetadata metadata = response.getMetadata(); + Usage usage = metadata.getUsage(); + assertThat(usage.getCompletionTokens()).isLessThanOrEqualTo(50); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/test/java/org/springframework/ai/model/replicate/autoconfigure/ReplicatePropertiesTests.java b/auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/test/java/org/springframework/ai/model/replicate/autoconfigure/ReplicatePropertiesTests.java new file mode 100644 index 00000000000..db2e0dcba0b --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-replicate/src/test/java/org/springframework/ai/model/replicate/autoconfigure/ReplicatePropertiesTests.java @@ -0,0 +1,138 @@ +/* + * 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.replicate.autoconfigure; + +import org.junit.jupiter.api.Test; + +import org.springframework.ai.replicate.ReplicateChatOptions; +import org.springframework.ai.replicate.ReplicateOptions; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for Replicate configuration properties. + * + * @author Rene Maierhofer + */ +class ReplicatePropertiesTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(ReplicateChatAutoConfiguration.class)); + + @Test + void testConnectionPropertiesBinding() { + this.contextRunner + .withPropertyValues("spring.ai.replicate.api-token=test-token", + "spring.ai.replicate.base-url=https://127.0.0.1/v1") + .run(context -> { + ReplicateConnectionProperties properties = context.getBean(ReplicateConnectionProperties.class); + assertThat(properties.getApiToken()).isEqualTo("test-token"); + assertThat(properties.getBaseUrl()).isEqualTo("https://127.0.0.1/v1"); + }); + } + + @Test + void testConnectionPropertiesDefaults() { + this.contextRunner.withPropertyValues("spring.ai.replicate.api-token=test-token").run(context -> { + ReplicateConnectionProperties properties = context.getBean(ReplicateConnectionProperties.class); + assertThat(properties.getBaseUrl()).isEqualTo(ReplicateConnectionProperties.DEFAULT_BASE_URL); + }); + } + + @Test + void testChatPropertiesWithInputParameters() { + this.contextRunner + .withPropertyValues("spring.ai.replicate.api-token=test-token", + "spring.ai.replicate.chat.options.model=meta/meta-llama-3-8b-instruct", + "spring.ai.replicate.chat.options.input.temperature=0.7", + "spring.ai.replicate.chat.options.input.max_tokens=100", + "spring.ai.replicate.chat.options.input.enabled=true") + .run(context -> { + ReplicateChatProperties properties = context.getBean(ReplicateChatProperties.class); + ReplicateChatOptions options = properties.getOptions(); + + assertThat(options.getInput()).isNotEmpty(); + assertThat(options.getInput().get("temperature")).isInstanceOf(Double.class).isEqualTo(0.7); + assertThat(options.getInput().get("max_tokens")).isInstanceOf(Integer.class).isEqualTo(100); + assertThat(options.getInput().get("enabled")).isInstanceOf(Boolean.class).isEqualTo(true); + }); + } + + @Test + void testMediaPropertiesBinding() { + this.contextRunner + .withPropertyValues("spring.ai.replicate.api-token=test-token", + "spring.ai.replicate.media.options.model=black-forest-labs/flux-schnell", + "spring.ai.replicate.media.options.version=media-version") + .run(context -> { + ReplicateMediaProperties properties = context.getBean(ReplicateMediaProperties.class); + ReplicateOptions options = properties.getOptions(); + + assertThat(options).isNotNull(); + assertThat(options.getModel()).isEqualTo("black-forest-labs/flux-schnell"); + assertThat(options.getVersion()).isEqualTo("media-version"); + }); + } + + @Test + void testMediaPropertiesWithInputParameters() { + this.contextRunner + .withPropertyValues("spring.ai.replicate.api-token=test-token", + "spring.ai.replicate.media.options.model=black-forest-labs/flux-schnell", + "spring.ai.replicate.media.options.input.prompt=test prompt", + "spring.ai.replicate.media.options.input.num_outputs=2") + .run(context -> { + ReplicateMediaProperties properties = context.getBean(ReplicateMediaProperties.class); + ReplicateOptions options = properties.getOptions(); + + assertThat(options.getInput()).isNotEmpty(); + assertThat(options.getInput().get("prompt")).isEqualTo("test prompt"); + assertThat(options.getInput().get("num_outputs")).isInstanceOf(Integer.class).isEqualTo(2); + }); + } + + @Test + void testStringPropertiesBinding() { + this.contextRunner + .withPropertyValues("spring.ai.replicate.api-token=test-token", + "spring.ai.replicate.string.options.model=falcons-ai/nsfw_image_detection") + .run(context -> { + ReplicateStringProperties properties = context.getBean(ReplicateStringProperties.class); + ReplicateOptions options = properties.getOptions(); + + assertThat(options).isNotNull(); + assertThat(options.getModel()).isEqualTo("falcons-ai/nsfw_image_detection"); + }); + } + + @Test + void testStructuredPropertiesBinding() { + this.contextRunner + .withPropertyValues("spring.ai.replicate.api-token=test-token", + "spring.ai.replicate.structured.options.model=openai/clip") + .run(context -> { + ReplicateStructuredProperties properties = context.getBean(ReplicateStructuredProperties.class); + ReplicateOptions options = properties.getOptions(); + + assertThat(options).isNotNull(); + assertThat(options.getModel()).isEqualTo("openai/clip"); + }); + } + +} diff --git a/models/spring-ai-replicate/README.md b/models/spring-ai-replicate/README.md new file mode 100644 index 00000000000..e9f5ebc9bda --- /dev/null +++ b/models/spring-ai-replicate/README.md @@ -0,0 +1,4 @@ +# Spring AI Replicate +Spring AI integration for [Replicate](https://replicate.com/) + +Replicate provides access to various models through a unified API. diff --git a/models/spring-ai-replicate/pom.xml b/models/spring-ai-replicate/pom.xml new file mode 100644 index 00000000000..34a4a60de5c --- /dev/null +++ b/models/spring-ai-replicate/pom.xml @@ -0,0 +1,85 @@ + + + + 4.0.0 + + org.springframework.ai + spring-ai-parent + 1.1.0-SNAPSHOT + ../../pom.xml + + spring-ai-replicate + jar + Spring AI Model - Replicate + Replicate AI models support + https://github.com/spring-projects/spring-ai + + + https://github.com/spring-projects/spring-ai + git://github.com/spring-projects/spring-ai.git + git@github.com:spring-projects/spring-ai.git + + + + + + + + org.springframework.ai + spring-ai-model + ${project.parent.version} + + + + org.springframework.ai + spring-ai-retry + ${project.parent.version} + + + + org.springframework + spring-context-support + + + + org.springframework + spring-webflux + + + + org.slf4j + slf4j-api + + + + + org.springframework.ai + spring-ai-test + ${project.version} + test + + + + io.micrometer + micrometer-observation-test + test + + + + + diff --git a/models/spring-ai-replicate/src/main/java/org/springframework/ai/replicate/ReplicateChatModel.java b/models/spring-ai-replicate/src/main/java/org/springframework/ai/replicate/ReplicateChatModel.java new file mode 100644 index 00000000000..017846a7f2a --- /dev/null +++ b/models/spring-ai-replicate/src/main/java/org/springframework/ai/replicate/ReplicateChatModel.java @@ -0,0 +1,313 @@ +/* + * 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.replicate; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +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 org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.metadata.ChatResponseMetadata; +import org.springframework.ai.chat.metadata.DefaultUsage; +import org.springframework.ai.chat.metadata.Usage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.model.MessageAggregator; +import org.springframework.ai.chat.observation.ChatModelObservationContext; +import org.springframework.ai.chat.observation.ChatModelObservationConvention; +import org.springframework.ai.chat.observation.ChatModelObservationDocumentation; +import org.springframework.ai.chat.observation.DefaultChatModelObservationConvention; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.replicate.api.ReplicateApi; +import org.springframework.ai.replicate.api.ReplicateApi.PredictionRequest; +import org.springframework.util.Assert; + +/** + * Replicate Chat Model implementation. + * + * @author Rene Maierhofer + * @since 1.1.0 + */ +public class ReplicateChatModel implements ChatModel { + + private static final ChatModelObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultChatModelObservationConvention(); + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private final ReplicateApi replicateApi; + + private final ObservationRegistry observationRegistry; + + private final ReplicateChatOptions defaultOptions; + + private final ChatModelObservationConvention observationConvention = DEFAULT_OBSERVATION_CONVENTION; + + public ReplicateChatModel(ReplicateApi replicateApi, ObservationRegistry observationRegistry, + ReplicateChatOptions defaultOptions) { + Assert.notNull(replicateApi, "replicateApi must not be null"); + Assert.notNull(observationRegistry, "observationRegistry must not be null"); + this.replicateApi = replicateApi; + this.observationRegistry = observationRegistry; + this.defaultOptions = defaultOptions; + } + + @Override + public ChatResponse call(Prompt prompt) { + return this.internalCall(prompt); + } + + @Override + public Flux stream(Prompt prompt) { + return this.internalStream(prompt); + } + + private ChatResponse internalCall(Prompt prompt) { + // Replicate does not support conversation history. + assert prompt.getUserMessages().size() == 1; + ReplicateChatOptions promptOptions = (ReplicateChatOptions) prompt.getOptions(); + ReplicateChatOptions requestOptions = mergeOptions(promptOptions); + PredictionRequest request = createRequestWithOptions(prompt, requestOptions, false); + + ChatModelObservationContext observationContext = ChatModelObservationContext.builder() + .prompt(prompt) + .provider(ReplicateApi.PROVIDER_NAME) + .build(); + + return ChatModelObservationDocumentation.CHAT_MODEL_OPERATION + .observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, + this.observationRegistry) + .observe(() -> { + ReplicateApi.PredictionResponse predictionResponse = this.replicateApi + .createPredictionAndWait(requestOptions.getModel(), request); + + if (predictionResponse == null) { + logger.warn("No prediction response returned for prompt: {}", prompt); + return new ChatResponse(List.of()); + } + + Map metadata = buildMetadataMap(predictionResponse); + + String content = extractContentFromOutput(predictionResponse.output()); + AssistantMessage assistantMessage = AssistantMessage.builder() + .content(content) + .properties(metadata) + .build(); + Generation generation = new Generation(assistantMessage); + DefaultUsage usage = getDefaultUsage(predictionResponse.metrics()); + ChatResponse chatResponse = new ChatResponse(List.of(generation), from(predictionResponse, usage)); + observationContext.setResponse(chatResponse); + + return chatResponse; + }); + } + + private static ChatResponseMetadata from(ReplicateApi.PredictionResponse result, Usage usage) { + return ChatResponseMetadata.builder() + .id(result.id()) + .model(result.model()) + .usage(usage) + .keyValue("created", result.createdAt()) + .keyValue("version", result.version()) + .build(); + } + + private static DefaultUsage getDefaultUsage(ReplicateApi.Metrics metrics) { + if (metrics == null) { + return new DefaultUsage(0, 0); + } + Integer inputTokens = metrics.inputTokenCount() != null ? metrics.inputTokenCount() : 0; + Integer outputTokens = metrics.outputTokenCount() != null ? metrics.outputTokenCount() : 0; + return new DefaultUsage(inputTokens, outputTokens); + } + + private Flux internalStream(Prompt prompt) { + return Flux.deferContextual(contextView -> { + ReplicateChatOptions promptOptions = (ReplicateChatOptions) prompt.getOptions(); + ReplicateChatOptions requestOptions = mergeOptions(promptOptions); + PredictionRequest request = createRequestWithOptions(prompt, requestOptions, true); + + ChatModelObservationContext observationContext = ChatModelObservationContext.builder() + .prompt(prompt) + .provider(ReplicateApi.PROVIDER_NAME) + .build(); + + Observation observation = ChatModelObservationDocumentation.CHAT_MODEL_OPERATION.observation( + this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, + this.observationRegistry); + + observation.parentObservation(contextView.getOrDefault(ObservationThreadLocalAccessor.KEY, null)).start(); + + Flux responseStream = this.replicateApi + .createPredictionStream(requestOptions.getModel(), request); + + Flux chatResponseFlux = responseStream.map(chunk -> { + String content = extractContentFromOutput(chunk.output()); + + AssistantMessage assistantMessage = AssistantMessage.builder() + .content(content) + .properties(buildMetadataMap(chunk)) + .build(); + + Generation generation = new Generation(assistantMessage); + DefaultUsage usage = getDefaultUsage(chunk.metrics()); + return new ChatResponse(List.of(generation), from(chunk, usage)); + }); + + // @formatter:off + return new MessageAggregator() + .aggregate(chatResponseFlux, observationContext::setResponse) + .doOnError(observation::error) + .doFinally(s -> observation.stop()) + .contextWrite(ctx -> ctx.put(ObservationThreadLocalAccessor.KEY, observation)); + // @formatter:on + }); + } + + /** + * Merges default options from properties with prompt options. Prompt options take + * precedence + * @param promptOptions Options from the current Prompt + * @return merged Options + */ + private ReplicateChatOptions mergeOptions(ReplicateChatOptions promptOptions) { + if (this.defaultOptions == null) { + return promptOptions != null ? promptOptions : ReplicateChatOptions.builder().build(); + } + if (promptOptions == null) { + return this.defaultOptions; + } + ReplicateChatOptions merged = ReplicateChatOptions.fromOptions(this.defaultOptions); + if (promptOptions.getModel() != null) { + merged.setModel(promptOptions.getModel()); + } + if (promptOptions.getVersion() != null) { + merged.setVersion(promptOptions.getVersion()); + } + if (promptOptions.getWebhook() != null) { + merged.setWebhook(promptOptions.getWebhook()); + } + if (promptOptions.getWebhookEventsFilter() != null) { + merged.setWebhookEventsFilter(promptOptions.getWebhookEventsFilter()); + } + Map mergedInput = new HashMap<>(); + if (this.defaultOptions.getInput() != null) { + mergedInput.putAll(this.defaultOptions.getInput()); + } + if (promptOptions.getInput() != null) { + mergedInput.putAll(promptOptions.getInput()); + } + merged.setInput(mergedInput); + + return merged; + } + + private PredictionRequest createRequestWithOptions(Prompt prompt, ReplicateChatOptions requestOptions, + boolean stream) { + Map input = new HashMap<>(); + if (requestOptions.getInput() != null) { + input.putAll(requestOptions.getInput()); + } + input.put("prompt", prompt.getUserMessage().getText()); + return new PredictionRequest(requestOptions.getVersion(), input, requestOptions.getWebhook(), + requestOptions.getWebhookEventsFilter(), stream); + } + + private Map buildMetadataMap(ReplicateApi.PredictionResponse response) { + Map metadata = new HashMap<>(); + if (response.id() != null) { + metadata.put("id", response.id()); + } + if (response.urls() != null) { + metadata.put("urls", response.urls()); + } + if (response.error() != null) { + metadata.put("error", response.error()); + } + if (response.logs() != null) { + metadata.put("logs", response.logs()); + } + return metadata; + } + + /** + * Extracts content from the output object. The output can be either a String or a + * List of Strings, depending on the model being used. + * @param output The output object from the prediction response + * @return The extracted content as a String, or empty string if null + */ + private static String extractContentFromOutput(Object output) { + if (output == null) { + return ""; + } + if (output instanceof String stringOutput) { + return stringOutput; + } + if (output instanceof List outputList) { + if (outputList.isEmpty()) { + return ""; + } + return outputList.stream().map(Object::toString).reduce("", (a, b) -> a + b); + } + // Fallback to toString for other types + return output.toString(); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private ReplicateApi replicateApi; + + private ObservationRegistry observationRegistry = ObservationRegistry.NOOP; + + private ReplicateChatOptions defaultOptions; + + private Builder() { + } + + public Builder replicateApi(ReplicateApi replicateApi) { + this.replicateApi = replicateApi; + return this; + } + + public Builder observationRegistry(ObservationRegistry observationRegistry) { + this.observationRegistry = observationRegistry; + return this; + } + + public Builder defaultOptions(ReplicateChatOptions defaultOptions) { + this.defaultOptions = defaultOptions; + return this; + } + + public ReplicateChatModel build() { + return new ReplicateChatModel(this.replicateApi, this.observationRegistry, this.defaultOptions); + } + + } + +} diff --git a/models/spring-ai-replicate/src/main/java/org/springframework/ai/replicate/ReplicateChatOptions.java b/models/spring-ai-replicate/src/main/java/org/springframework/ai/replicate/ReplicateChatOptions.java new file mode 100644 index 00000000000..53609ba776f --- /dev/null +++ b/models/spring-ai-replicate/src/main/java/org/springframework/ai/replicate/ReplicateChatOptions.java @@ -0,0 +1,240 @@ +/* + * 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.replicate; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.springframework.ai.chat.prompt.ChatOptions; + +/** + * Base options for Replicate models. Contains common fields that apply to all Replicate + * models regardless of type (chat, image, audio, etc.). + * + * @author Rene Maierhofer + * @since 1.1.0 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ReplicateChatOptions implements ChatOptions { + + @JsonProperty("model") + protected String model; + + @JsonProperty("version") + protected String version; + + @JsonProperty("input") + protected Map input = new HashMap<>(); + + @JsonProperty("webhook") + protected String webhook; + + @JsonProperty("webhook_events_filter") + protected List webhookEventsFilter; + + public ReplicateChatOptions() { + } + + protected ReplicateChatOptions(Builder builder) { + this.model = builder.model; + this.version = builder.version; + this.input = builder.input != null ? new HashMap<>(builder.input) : new HashMap<>(); + this.webhook = builder.webhook; + this.webhookEventsFilter = builder.webhookEventsFilter; + } + + /** + * Add a custom parameter to the model input + */ + public ReplicateChatOptions withParameter(String key, Object value) { + this.input.put(key, value); + return this; + } + + /** + * Add multiple parameters to the model input + */ + public ReplicateChatOptions withParameters(Map parameters) { + this.input.putAll(parameters); + return this; + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Create a new ReplicateOptions from existing options + */ + public static ReplicateChatOptions fromOptions(ReplicateChatOptions fromOptions) { + return builder().model(fromOptions.getModel()) + .version(fromOptions.getVersion()) + .input(new HashMap<>(fromOptions.getInput())) + .webhook(fromOptions.getWebhook()) + .webhookEventsFilter(fromOptions.getWebhookEventsFilter()) + .build(); + } + + public String getModel() { + return this.model; + } + + @Override + @com.fasterxml.jackson.annotation.JsonIgnore + public Double getFrequencyPenalty() { + return null; + } + + @Override + @com.fasterxml.jackson.annotation.JsonIgnore + public Integer getMaxTokens() { + return null; + } + + @Override + @com.fasterxml.jackson.annotation.JsonIgnore + public Double getPresencePenalty() { + return null; + } + + @Override + @com.fasterxml.jackson.annotation.JsonIgnore + public List getStopSequences() { + return null; + } + + @Override + @com.fasterxml.jackson.annotation.JsonIgnore + public Double getTemperature() { + return null; + } + + @Override + @com.fasterxml.jackson.annotation.JsonIgnore + public Integer getTopK() { + return null; + } + + @Override + @com.fasterxml.jackson.annotation.JsonIgnore + public Double getTopP() { + return null; + } + + @Override + @SuppressWarnings("unchecked") + public T copy() { + return (T) fromOptions(this); + } + + public void setModel(String model) { + this.model = model; + } + + public String getVersion() { + return this.version; + } + + public void setVersion(String version) { + this.version = version; + } + + public Map getInput() { + return this.input; + } + + public void setInput(Map input) { + this.input = ReplicateOptionsUtils.convertMapValues(input); + } + + public String getWebhook() { + return this.webhook; + } + + public void setWebhook(String webhook) { + this.webhook = webhook; + } + + public List getWebhookEventsFilter() { + return this.webhookEventsFilter; + } + + public void setWebhookEventsFilter(List webhookEventsFilter) { + this.webhookEventsFilter = webhookEventsFilter; + } + + public static class Builder { + + protected String model; + + protected String version; + + protected Map input = new HashMap<>(); + + protected String webhook; + + protected List webhookEventsFilter; + + protected Builder() { + } + + public Builder model(String model) { + this.model = model; + return this; + } + + public Builder version(String version) { + this.version = version; + return this; + } + + private Builder input(Map input) { + this.input = input; + return this; + } + + public Builder withParameter(String key, Object value) { + this.input.put(key, value); + return this; + } + + public Builder withParameters(Map params) { + this.input.putAll(params); + return this; + } + + public Builder webhook(String webhook) { + this.webhook = webhook; + return this; + } + + public Builder webhookEventsFilter(List webhookEventsFilter) { + this.webhookEventsFilter = webhookEventsFilter; + return this; + } + + public ReplicateChatOptions build() { + return new ReplicateChatOptions(this); + } + + } + +} diff --git a/models/spring-ai-replicate/src/main/java/org/springframework/ai/replicate/ReplicateMediaModel.java b/models/spring-ai-replicate/src/main/java/org/springframework/ai/replicate/ReplicateMediaModel.java new file mode 100644 index 00000000000..f83ab8fac90 --- /dev/null +++ b/models/spring-ai-replicate/src/main/java/org/springframework/ai/replicate/ReplicateMediaModel.java @@ -0,0 +1,208 @@ +/* + * 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.replicate; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.replicate.api.ReplicateApi; +import org.springframework.ai.replicate.api.ReplicateApi.PredictionRequest; +import org.springframework.ai.replicate.api.ReplicateApi.PredictionResponse; +import org.springframework.util.Assert; + +/** + * Replicate Media Model implementation for image, video, and audio generation. Handles + * both single URI outputs and multiple URI outputs (arrays). + * + * @author Rene Maierhofer + * @since 1.1.0 + */ +public class ReplicateMediaModel { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private final ReplicateApi replicateApi; + + private final ReplicateOptions defaultOptions; + + public ReplicateMediaModel(ReplicateApi replicateApi, ReplicateOptions defaultOptions) { + Assert.notNull(replicateApi, "replicateApi must not be null"); + this.replicateApi = replicateApi; + this.defaultOptions = defaultOptions; + } + + /** + * Generate media (image/video/audio) using the specified model and options. + * @param options The model configuration including model name and input. + * @return Response containing URIs to generated media files + */ + public MediaResponse generate(ReplicateOptions options) { + ReplicateOptions mergedOptions = mergeOptions(options); + Assert.hasText(mergedOptions.getModel(), "model name must not be empty"); + + PredictionRequest request = new PredictionRequest(mergedOptions.getVersion(), mergedOptions.getInput(), + mergedOptions.getWebhook(), mergedOptions.getWebhookEventsFilter(), false); + + PredictionResponse predictionResponse = this.replicateApi.createPredictionAndWait(mergedOptions.getModel(), + request); + + if (predictionResponse == null) { + logger.warn("No prediction response returned for model: {}", mergedOptions.getModel()); + return new MediaResponse(Collections.emptyList(), predictionResponse); + } + + List uris = parseMediaOutput(predictionResponse.output()); + return new MediaResponse(uris, predictionResponse); + } + + /** + * Merges default options from properties with prompt options. Prompt options take + * precedence + * @param providedOptions Options from the current Prompt + * @return merged Options + */ + private ReplicateOptions mergeOptions(ReplicateOptions providedOptions) { + if (this.defaultOptions == null) { + return providedOptions != null ? providedOptions : ReplicateOptions.builder().build(); + } + + if (providedOptions == null) { + return this.defaultOptions; + } + ReplicateOptions merged = ReplicateOptions.fromOptions(this.defaultOptions); + if (providedOptions.getModel() != null) { + merged.setModel(providedOptions.getModel()); + } + if (providedOptions.getVersion() != null) { + merged.setVersion(providedOptions.getVersion()); + } + if (providedOptions.getWebhook() != null) { + merged.setWebhook(providedOptions.getWebhook()); + } + if (providedOptions.getWebhookEventsFilter() != null) { + merged.setWebhookEventsFilter(providedOptions.getWebhookEventsFilter()); + } + Map mergedInput = new HashMap<>(); + if (this.defaultOptions.getInput() != null) { + mergedInput.putAll(this.defaultOptions.getInput()); + } + if (providedOptions.getInput() != null) { + mergedInput.putAll(providedOptions.getInput()); + } + merged.setInput(mergedInput); + + return merged; + } + + /** + * Parse the output field which can be either a single URI string or an array of URI + * strings. + */ + private List parseMediaOutput(Object output) { + if (output == null) { + return Collections.emptyList(); + } + if (output instanceof String outputString) { + return List.of(outputString); + } + if (output instanceof List) { + List list = (List) output; + List uris = new ArrayList<>(); + for (Object item : list) { + if (item instanceof String itemString) { + uris.add(itemString); + } + } + return uris; + } + logger.warn("Unexpected output type: {}", output.getClass().getName()); + return Collections.emptyList(); + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Response containing generated media URIs and the raw prediction response. + */ + public static class MediaResponse { + + private final List uris; + + private final PredictionResponse predictionResponse; + + public MediaResponse(List uris, PredictionResponse predictionResponse) { + this.uris = uris != null ? Collections.unmodifiableList(uris) : Collections.emptyList(); + this.predictionResponse = predictionResponse; + } + + /** + * Get the list of URIs pointing to generated media files. + */ + public List getUris() { + return this.uris; + } + + /** + * Get the first URI if available, useful for single-file outputs. + */ + public String getFirstUri() { + return this.uris.isEmpty() ? null : this.uris.get(0); + } + + /** + * Get the raw prediction response from Replicate API. + */ + public PredictionResponse getPredictionResponse() { + return this.predictionResponse; + } + + } + + public static final class Builder { + + private ReplicateApi replicateApi; + + private ReplicateOptions defaultOptions; + + private Builder() { + } + + public Builder replicateApi(ReplicateApi replicateApi) { + this.replicateApi = replicateApi; + return this; + } + + public Builder defaultOptions(ReplicateOptions defaultOptions) { + this.defaultOptions = defaultOptions; + return this; + } + + public ReplicateMediaModel build() { + return new ReplicateMediaModel(this.replicateApi, this.defaultOptions); + } + + } + +} diff --git a/models/spring-ai-replicate/src/main/java/org/springframework/ai/replicate/ReplicateOptions.java b/models/spring-ai-replicate/src/main/java/org/springframework/ai/replicate/ReplicateOptions.java new file mode 100644 index 00000000000..a83f23297e4 --- /dev/null +++ b/models/spring-ai-replicate/src/main/java/org/springframework/ai/replicate/ReplicateOptions.java @@ -0,0 +1,207 @@ +/* + * 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.replicate; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.springframework.ai.model.ModelOptions; + +/** + * Base options for Replicate models. Contains common fields that apply to all Replicate + * models regardless of type (chat, image, audio, etc.). + * + * @author Rene Maierhofer + * @since 1.1.0 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ReplicateOptions implements ModelOptions { + + /** + * The model identifier in format "owner/model-name" (e.g., "meta/llama-2-70b-chat") + */ + protected String model; + + /** + * The specific version hash of the model to use. Not mandatory for "official" models. + */ + @JsonProperty("version") + protected String version; + + /** + * Flexible input map containing model-specific parameters. This allows support for + * any model on Replicate, regardless of its specific input schema. + */ + @JsonProperty("input") + protected Map input = new HashMap<>(); + + /** + * Optional webhook URL for async notifications + */ + @JsonProperty("webhook") + protected String webhook; + + /** + * Optional webhook events to subscribe to + */ + @JsonProperty("webhook_events_filter") + protected List webhookEventsFilter; + + public ReplicateOptions() { + } + + protected ReplicateOptions(Builder builder) { + this.model = builder.model; + this.version = builder.version; + this.input = builder.input != null ? new HashMap<>(builder.input) : new HashMap<>(); + this.webhook = builder.webhook; + this.webhookEventsFilter = builder.webhookEventsFilter; + } + + /** + * Add a custom parameter to the model input + */ + public ReplicateOptions withParameter(String key, Object value) { + this.input.put(key, value); + return this; + } + + /** + * Add multiple parameters to the model input + */ + public ReplicateOptions withParameters(Map parameters) { + this.input.putAll(parameters); + return this; + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Create a new ReplicateOptions from existing options + */ + public static ReplicateOptions fromOptions(ReplicateOptions fromOptions) { + return builder().model(fromOptions.getModel()) + .version(fromOptions.getVersion()) + .input(new HashMap<>(fromOptions.getInput())) + .webhook(fromOptions.getWebhook()) + .webhookEventsFilter(fromOptions.getWebhookEventsFilter()) + .build(); + } + + public String getModel() { + return this.model; + } + + public void setModel(String model) { + this.model = model; + } + + public String getVersion() { + return this.version; + } + + public void setVersion(String version) { + this.version = version; + } + + public Map getInput() { + return this.input; + } + + public void setInput(Map input) { + this.input = ReplicateOptionsUtils.convertMapValues(input); + } + + public String getWebhook() { + return this.webhook; + } + + public void setWebhook(String webhook) { + this.webhook = webhook; + } + + public List getWebhookEventsFilter() { + return this.webhookEventsFilter; + } + + public void setWebhookEventsFilter(List webhookEventsFilter) { + this.webhookEventsFilter = webhookEventsFilter; + } + + public static class Builder { + + protected String model; + + protected String version; + + protected Map input = new HashMap<>(); + + protected String webhook; + + protected List webhookEventsFilter; + + protected Builder() { + } + + public Builder model(String model) { + this.model = model; + return this; + } + + public Builder version(String version) { + this.version = version; + return this; + } + + private Builder input(Map input) { + this.input = input; + return this; + } + + public Builder withParameter(String key, Object value) { + this.input.put(key, value); + return this; + } + + public Builder withParameters(Map params) { + this.input.putAll(params); + return this; + } + + public Builder webhook(String webhook) { + this.webhook = webhook; + return this; + } + + public Builder webhookEventsFilter(List webhookEventsFilter) { + this.webhookEventsFilter = webhookEventsFilter; + return this; + } + + public ReplicateOptions build() { + return new ReplicateOptions(this); + } + + } + +} diff --git a/models/spring-ai-replicate/src/main/java/org/springframework/ai/replicate/ReplicateOptionsUtils.java b/models/spring-ai-replicate/src/main/java/org/springframework/ai/replicate/ReplicateOptionsUtils.java new file mode 100644 index 00000000000..17c5ea5a2da --- /dev/null +++ b/models/spring-ai-replicate/src/main/java/org/springframework/ai/replicate/ReplicateOptionsUtils.java @@ -0,0 +1,84 @@ +/* + * 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.replicate; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.lang.Nullable; + +/** + * Utility class for handling Replicate options, when set via application.properties. This + * utility is needed because replicate expects various different Types in the "input" map + * and we cannot automatically infer the type from the properties. + * + * @author Rene Maierhofer + * @since 1.1.0 + */ +public abstract class ReplicateOptionsUtils { + + private ReplicateOptionsUtils() { + } + + /** + * Convert all string values in a map to their appropriate types + * @param source the source map with potentially string-typed values + * @return a new map with properly typed values, or an empty map if source is null + */ + public static Map convertMapValues(@Nullable Map source) { + if (source == null) { + return new HashMap<>(); + } + Map result = new HashMap<>(source.size()); + for (Map.Entry entry : source.entrySet()) { + result.put(entry.getKey(), convertValue(entry.getValue())); + } + return result; + } + + /** + * Convert a value to its appropriate type if it's a string representation of a number + * or boolean. Non-string values are returned as-is. + * @param value the value to convert + * @return the converted value with the appropriate type + */ + public static Object convertValue(@Nullable Object value) { + if (!(value instanceof String strValue)) { + return value; + } + if ("true".equalsIgnoreCase(strValue) || "false".equalsIgnoreCase(strValue)) { + return Boolean.parseBoolean(strValue); + } + if (!strValue.contains(".") && !strValue.contains("e") && !strValue.contains("E")) { + try { + return Integer.parseInt(strValue); + } + catch (NumberFormatException ex) { + // Not an integer, continue to next type check + } + } + try { + return Double.parseDouble(strValue); + } + catch (NumberFormatException ex) { + // Not a number, return as string + } + // Return as string + return strValue; + } + +} diff --git a/models/spring-ai-replicate/src/main/java/org/springframework/ai/replicate/ReplicateStringModel.java b/models/spring-ai-replicate/src/main/java/org/springframework/ai/replicate/ReplicateStringModel.java new file mode 100644 index 00000000000..2518b3af4c8 --- /dev/null +++ b/models/spring-ai-replicate/src/main/java/org/springframework/ai/replicate/ReplicateStringModel.java @@ -0,0 +1,174 @@ +/* + * 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.replicate; + +import java.util.HashMap; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.replicate.api.ReplicateApi; +import org.springframework.ai.replicate.api.ReplicateApi.PredictionRequest; +import org.springframework.ai.replicate.api.ReplicateApi.PredictionResponse; +import org.springframework.util.Assert; + +/** + * Replicate String Model implementation for models that return simple string outputs. + * Typically used by classifiers, filters, or small utility models. + * + * @author Rene Maierhofer + * @since 1.1.0 + */ +public class ReplicateStringModel { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private final ReplicateApi replicateApi; + + private final ReplicateOptions defaultOptions; + + public ReplicateStringModel(ReplicateApi replicateApi, ReplicateOptions defaultOptions) { + Assert.notNull(replicateApi, "replicateApi must not be null"); + this.replicateApi = replicateApi; + this.defaultOptions = defaultOptions; + } + + /** + * Generate a string output using the specified model and options. + * @param options The model configuration including model name, and input + * @return Response containing the string output + */ + public StringResponse generate(ReplicateOptions options) { + ReplicateOptions mergedOptions = mergeOptions(options); + Assert.hasText(mergedOptions.getModel(), "model name must not be empty"); + + PredictionRequest request = new PredictionRequest(mergedOptions.getVersion(), mergedOptions.getInput(), + mergedOptions.getWebhook(), mergedOptions.getWebhookEventsFilter(), false); + + PredictionResponse predictionResponse = this.replicateApi.createPredictionAndWait(mergedOptions.getModel(), + request); + + if (predictionResponse == null) { + logger.warn("No prediction response returned for model: {}", mergedOptions.getModel()); + return new StringResponse(null, predictionResponse); + } + + String output = parseStringOutput(predictionResponse.output()); + return new StringResponse(output, predictionResponse); + } + + /** + * Merges default options from properties with prompt options. Prompt options take + * precedence + * @param providedOptions Options from the current Prompt + * @return merged Options + */ + private ReplicateOptions mergeOptions(ReplicateOptions providedOptions) { + if (this.defaultOptions == null) { + return providedOptions != null ? providedOptions : ReplicateOptions.builder().build(); + } + if (providedOptions == null) { + return this.defaultOptions; + } + ReplicateOptions merged = ReplicateOptions.fromOptions(this.defaultOptions); + if (providedOptions.getModel() != null) { + merged.setModel(providedOptions.getModel()); + } + if (providedOptions.getVersion() != null) { + merged.setVersion(providedOptions.getVersion()); + } + if (providedOptions.getWebhook() != null) { + merged.setWebhook(providedOptions.getWebhook()); + } + if (providedOptions.getWebhookEventsFilter() != null) { + merged.setWebhookEventsFilter(providedOptions.getWebhookEventsFilter()); + } + Map mergedInput = new HashMap<>(); + if (this.defaultOptions.getInput() != null) { + mergedInput.putAll(this.defaultOptions.getInput()); + } + if (providedOptions.getInput() != null) { + mergedInput.putAll(providedOptions.getInput()); + } + merged.setInput(mergedInput); + + return merged; + } + + private String parseStringOutput(Object output) { + if (output == null) { + return null; + } + if (output instanceof String outputString) { + return outputString; + } + logger.warn("Unexpected output type for string model: {}, converting to string", output.getClass().getName()); + return output.toString(); + } + + public static Builder builder() { + return new Builder(); + } + + public static class StringResponse { + + private final String output; + + private final PredictionResponse predictionResponse; + + public StringResponse(String output, PredictionResponse predictionResponse) { + this.output = output; + this.predictionResponse = predictionResponse; + } + + public String getOutput() { + return this.output; + } + + public PredictionResponse getPredictionResponse() { + return this.predictionResponse; + } + + } + + public static final class Builder { + + private ReplicateApi replicateApi; + + private ReplicateOptions defaultOptions; + + private Builder() { + } + + public Builder replicateApi(ReplicateApi replicateApi) { + this.replicateApi = replicateApi; + return this; + } + + public Builder defaultOptions(ReplicateOptions defaultOptions) { + this.defaultOptions = defaultOptions; + return this; + } + + public ReplicateStringModel build() { + return new ReplicateStringModel(this.replicateApi, this.defaultOptions); + } + + } + +} diff --git a/models/spring-ai-replicate/src/main/java/org/springframework/ai/replicate/ReplicateStructuredModel.java b/models/spring-ai-replicate/src/main/java/org/springframework/ai/replicate/ReplicateStructuredModel.java new file mode 100644 index 00000000000..1375f426aa4 --- /dev/null +++ b/models/spring-ai-replicate/src/main/java/org/springframework/ai/replicate/ReplicateStructuredModel.java @@ -0,0 +1,220 @@ +/* + * 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.replicate; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.replicate.api.ReplicateApi; +import org.springframework.ai.replicate.api.ReplicateApi.PredictionRequest; +import org.springframework.ai.replicate.api.ReplicateApi.PredictionResponse; +import org.springframework.util.Assert; + +/** + * Replicate Structured Model implementation for models that return structured JSON + * objects. + * + * @author Rene Maierhofer + * @since 1.1.0 + */ +public class ReplicateStructuredModel { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private final ReplicateApi replicateApi; + + private final ReplicateOptions defaultOptions; + + public ReplicateStructuredModel(ReplicateApi replicateApi, ReplicateOptions defaultOptions) { + Assert.notNull(replicateApi, "replicateApi must not be null"); + this.replicateApi = replicateApi; + this.defaultOptions = defaultOptions; + } + + /** + * Generate structured output using the specified model and options. + * @param options The model configuration including model name and input + * @return Response containing the structured output as a Map + */ + public StructuredResponse generate(ReplicateOptions options) { + ReplicateOptions mergedOptions = mergeOptions(options); + Assert.hasText(mergedOptions.getModel(), "model name must not be empty"); + + PredictionRequest request = new PredictionRequest(mergedOptions.getVersion(), mergedOptions.getInput(), + mergedOptions.getWebhook(), mergedOptions.getWebhookEventsFilter(), false); + + PredictionResponse predictionResponse = this.replicateApi.createPredictionAndWait(mergedOptions.getModel(), + request); + + if (predictionResponse == null) { + logger.warn("No prediction response returned for model: {}", mergedOptions.getModel()); + return new StructuredResponse(Collections.emptyMap(), predictionResponse); + } + + Map structuredOutput = parseStructuredOutput(predictionResponse.output()); + return new StructuredResponse(structuredOutput, predictionResponse); + } + + /** + * Merges default options from properties with prompt options. Prompt options take + * precedence + * @param providedOptions Options from the current Prompt + * @return merged Options + */ + private ReplicateOptions mergeOptions(ReplicateOptions providedOptions) { + if (this.defaultOptions == null) { + return providedOptions != null ? providedOptions : ReplicateOptions.builder().build(); + } + if (providedOptions == null) { + return this.defaultOptions; + } + ReplicateOptions merged = ReplicateOptions.fromOptions(this.defaultOptions); + if (providedOptions.getModel() != null) { + merged.setModel(providedOptions.getModel()); + } + if (providedOptions.getVersion() != null) { + merged.setVersion(providedOptions.getVersion()); + } + if (providedOptions.getWebhook() != null) { + merged.setWebhook(providedOptions.getWebhook()); + } + if (providedOptions.getWebhookEventsFilter() != null) { + merged.setWebhookEventsFilter(providedOptions.getWebhookEventsFilter()); + } + Map mergedInput = new HashMap<>(); + if (this.defaultOptions.getInput() != null) { + mergedInput.putAll(this.defaultOptions.getInput()); + } + if (providedOptions.getInput() != null) { + mergedInput.putAll(providedOptions.getInput()); + } + merged.setInput(mergedInput); + + return merged; + } + + /** + * Parse the output field as a Map. + */ + @SuppressWarnings("unchecked") + private Map parseStructuredOutput(Object output) { + if (output == null) { + return Collections.emptyMap(); + } + + if (output instanceof Map) { + return (Map) output; + } + + logger.warn("Unexpected output type for structured model: {}", output.getClass().getName()); + return Collections.emptyMap(); + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Response containing structured output as a Map and the raw prediction response. + */ + public static class StructuredResponse { + + private final Map output; + + private final PredictionResponse predictionResponse; + + public StructuredResponse(Map output, PredictionResponse predictionResponse) { + this.output = output != null ? Collections.unmodifiableMap(output) : Collections.emptyMap(); + this.predictionResponse = predictionResponse; + } + + /** + * Get the structured output as a Map. + */ + public Map getOutput() { + return this.output; + } + + /** + * Get the raw prediction response from Replicate API. + */ + public PredictionResponse getPredictionResponse() { + return this.predictionResponse; + } + + } + + /** + * Response containing structured output converted to a specific type. + */ + public static class TypedStructuredResponse { + + private final T output; + + private final PredictionResponse predictionResponse; + + public TypedStructuredResponse(T output, PredictionResponse predictionResponse) { + this.output = output; + this.predictionResponse = predictionResponse; + } + + /** + * Get the structured output as the specified type. + */ + public T getOutput() { + return this.output; + } + + /** + * Get the raw prediction response from Replicate API. + */ + public PredictionResponse getPredictionResponse() { + return this.predictionResponse; + } + + } + + public static final class Builder { + + private ReplicateApi replicateApi; + + private ReplicateOptions defaultOptions; + + private Builder() { + } + + public Builder replicateApi(ReplicateApi replicateApi) { + this.replicateApi = replicateApi; + return this; + } + + public Builder defaultOptions(ReplicateOptions defaultOptions) { + this.defaultOptions = defaultOptions; + return this; + } + + public ReplicateStructuredModel build() { + return new ReplicateStructuredModel(this.replicateApi, this.defaultOptions); + } + + } + +} diff --git a/models/spring-ai-replicate/src/main/java/org/springframework/ai/replicate/api/ReplicateApi.java b/models/spring-ai-replicate/src/main/java/org/springframework/ai/replicate/api/ReplicateApi.java new file mode 100644 index 00000000000..1719e040360 --- /dev/null +++ b/models/spring-ai-replicate/src/main/java/org/springframework/ai/replicate/api/ReplicateApi.java @@ -0,0 +1,424 @@ +/* + * 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.replicate.api; + +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; + +import org.springframework.ai.model.ApiKey; +import org.springframework.ai.model.SimpleApiKey; +import org.springframework.ai.observation.conventions.AiProvider; +import org.springframework.ai.retry.RetryUtils; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.io.Resource; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.http.codec.ServerSentEvent; +import org.springframework.retry.RetryCallback; +import org.springframework.retry.RetryContext; +import org.springframework.retry.RetryListener; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.util.Assert; +import org.springframework.web.client.ResponseErrorHandler; +import org.springframework.web.client.RestClient; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * Client for the Replicate Predictions API + * + * @author Rene Maierhofer + * @since 1.1.0 + */ +public final class ReplicateApi { + + private static final Logger logger = LoggerFactory.getLogger(ReplicateApi.class); + + private static final String DEFAULT_BASE_URL = "https://api.replicate.com/v1"; + + private static final String PREDICTIONS_PATH = "/predictions"; + + private final RestClient restClient; + + private final WebClient webClient; + + private final RetryTemplate retryTemplate = RetryTemplate.builder() + .retryOn(ReplicatePredictionNotFinishedException.class) + .maxAttempts(60) + .fixedBackoff(5000) + .withListener(new RetryListener() { + @Override + public void onError(RetryContext context, RetryCallback callback, + Throwable throwable) { + logger.debug("Polling Replicate Prediction: {}/10 attempts.", context.getRetryCount()); + } + }) + .build(); + + public static final String PROVIDER_NAME = AiProvider.REPLICATE.value(); + + private ReplicateApi(String baseUrl, ApiKey apiKey, RestClient.Builder restClientBuilder, + WebClient.Builder webClientBuilder, ResponseErrorHandler responseErrorHandler) { + Consumer headers = h -> { + h.setContentType(MediaType.APPLICATION_JSON); + h.setBearerAuth(apiKey.getValue()); + }; + + this.restClient = restClientBuilder.baseUrl(baseUrl) + .defaultHeaders(headers) + .defaultStatusHandler(responseErrorHandler) + .build(); + + this.webClient = webClientBuilder.clone().baseUrl(baseUrl).defaultHeaders(headers).build(); + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Creates a Prediction using the model's endpoint. + * @param modelName The model name in format "owner/name" (e.g., "openai/gpt-5") + * @param request The prediction request + * @return The prediction response + */ + public PredictionResponse createPrediction(String modelName, PredictionRequest request) { + Assert.hasText(modelName, "Model name must not be empty"); + + String uri = "/models/" + modelName + PREDICTIONS_PATH; + ResponseEntity response = this.restClient.post() + .uri(uri) + .body(request) + .retrieve() + .toEntity(PredictionResponse.class); + return response.getBody(); + } + + /** + * Retrieves the current status of the Prediction + * @param predictionId The prediction ID + * @return The prediction response + */ + public PredictionResponse getPrediction(String predictionId) { + Assert.hasText(predictionId, "Prediction ID must not be empty"); + + return this.restClient.get() + .uri(PREDICTIONS_PATH + "/{id}", predictionId) + .retrieve() + .body(PredictionResponse.class); + } + + /** + * Creates a prediction and waits for it to complete by polling the status. Uses the + * configured retry template. + * @param modelName The model name in format "owner/name" + * @param request The prediction request + * @return The completed prediction response + */ + public PredictionResponse createPredictionAndWait(String modelName, PredictionRequest request) { + PredictionResponse prediction = createPrediction(modelName, request); + if (prediction == null || prediction.id == null) { + throw new ReplicatePredictionException("PredictionRequest did not return a valid response."); + } + return waitForCompletion(prediction.id()); + } + + /** + * Waits for the completed Prediction and returns the final Response. + * @param predictionId id of the prediction + * @return the final PredictionResponse + */ + public PredictionResponse waitForCompletion(String predictionId) { + Assert.hasText(predictionId, "Prediction ID must not be empty"); + return this.retryTemplate.execute(context -> pollStatusFromReplicate(predictionId)); + } + + /** + * Polls the prediction status from replicate. + * @param predictionId the Prediction's id + * @return the final Prediction Response + */ + private PredictionResponse pollStatusFromReplicate(String predictionId) { + PredictionResponse prediction = getPrediction(predictionId); + if (prediction == null || prediction.id == null) { + throw new ReplicatePredictionException("Polling for Prediction did not return a valid response."); + } + PredictionStatus status = prediction.status(); + if (status == PredictionStatus.SUCCEEDED) { + return prediction; + } + else if (status == PredictionStatus.PROCESSING || status == PredictionStatus.STARTING) { + throw new ReplicatePredictionNotFinishedException("Prediction not finished yet."); + } + else if (status == PredictionStatus.FAILED) { + String error = prediction.error() != null ? prediction.error() : "Unknown error"; + throw new ReplicatePredictionException("Prediction failed: " + error); + } + else if (status == PredictionStatus.CANCELED || status == PredictionStatus.ABORTED) { + throw new ReplicatePredictionException("Prediction was canceled"); + } + throw new ReplicatePredictionException("Unknown Replicate Prediction Status"); + } + + /** + * Uploads a file to Replicate for usage in a request. Replicate + * Files API + * @param fileResource The file to upload + * @param filename The filename to use for the uploaded file + * @return Upload response containing the URL to later send with a request. + */ + public FileUploadResponse uploadFile(Resource fileResource, String filename) { + Assert.notNull(fileResource, "File resource must not be null"); + Assert.hasText(filename, "Filename must not be empty"); + + MultipartBodyBuilder builder = new MultipartBodyBuilder(); + builder.part("content", fileResource) + .headers(h -> h + .setContentDisposition(ContentDisposition.formData().name("content").filename(filename).build())) + .contentType(MediaType.APPLICATION_OCTET_STREAM); + + return this.webClient.post() + .uri("/files") + .contentType(MediaType.MULTIPART_FORM_DATA) + .bodyValue(builder.build()) + .retrieve() + .bodyToMono(FileUploadResponse.class) + .block(); + } + + /** + * Creates a streaming prediction response. Replicate uses SSE for Streaming. + * Replicate + * Docs + * @param modelName The model name in format "owner/name" + * @param request The prediction request (must have stream=true) + * @return A Flux stream of prediction response events with incremental output + */ + public Flux createPredictionStream(String modelName, PredictionRequest request) { + PredictionResponse initialResponse = createPrediction(modelName, request); + if (initialResponse.urls() == null || initialResponse.urls().stream() == null) { + logger.error("No stream URL in response: {}", initialResponse); + return Flux.error(new ReplicatePredictionException("No stream URL returned from prediction")); + } + String streamUrl = initialResponse.urls().stream(); + ParameterizedTypeReference> typeRef = new ParameterizedTypeReference<>() { + }; + + return this.webClient.get() + .uri(streamUrl) + .accept(MediaType.TEXT_EVENT_STREAM) + .header(HttpHeaders.CACHE_CONTROL, "no-store") + .retrieve() + .bodyToFlux(typeRef) + .handle((event, sink) -> { + String eventType = event.event(); + if ("error".equals(eventType)) { + String errorMessage = event.data() != null ? event.data() : "Unknown error"; + sink.error(new ReplicatePredictionException("Streaming error: " + errorMessage)); + return; + } + if ("done".equals(eventType)) { + sink.complete(); + return; + } + if ("output".equals(eventType)) { + String dataContent = event.data() != null ? event.data() : ""; + PredictionResponse response = new PredictionResponse(initialResponse.id(), initialResponse.model(), + initialResponse.version(), PredictionStatus.PROCESSING, initialResponse.input(), + dataContent, // The output chunk + null, null, null, initialResponse.urls(), initialResponse.createdAt(), + initialResponse.startedAt(), null); + + sink.next(response); + } + }); + } + + /** + * Request to create a prediction + * + * @param version Optional model version + * @param input The input parameters for the model + * @param webhook Optional webhook URL for async notifications + * @param webhookEventsFilter Optional list of webhook events to subscribe to + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record PredictionRequest(@JsonProperty("version") String version, + @JsonProperty("input") Map input, @JsonProperty("webhook") String webhook, + @JsonProperty("webhook_events_filter") List webhookEventsFilter, + @JsonProperty("stream") boolean stream) { + } + + /** + * Response from Replicate prediction API. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record PredictionResponse(@JsonProperty("id") String id, @JsonProperty("model") String model, + @JsonProperty("version") String version, @JsonProperty("status") PredictionStatus status, + @JsonProperty("input") Map input, @JsonProperty("output") Object output, + @JsonProperty("error") String error, @JsonProperty("logs") String logs, + @JsonProperty("metrics") Metrics metrics, @JsonProperty("urls") Urls urls, + @JsonProperty("created_at") String createdAt, @JsonProperty("started_at") String startedAt, + @JsonProperty("completed_at") String completedAt) { + } + + /** + * Prediction status. + */ + public enum PredictionStatus { + + @JsonProperty("starting") + STARTING, + + @JsonProperty("processing") + PROCESSING, + + @JsonProperty("succeeded") + SUCCEEDED, + + @JsonProperty("failed") + FAILED, + + @JsonProperty("canceled") + CANCELED, + + @JsonProperty("aborted") + ABORTED + + } + + /** + * Metrics from a prediction including token counts and timing. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record Metrics(@JsonProperty("predict_time") Double predictTime, + @JsonProperty("input_token_count") Integer inputTokenCount, + @JsonProperty("output_token_count") Integer outputTokenCount) { + } + + /** + * URLs for interacting with a prediction. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record Urls(@JsonProperty("get") String get, @JsonProperty("cancel") String cancel, + @JsonProperty("stream") String stream) { + } + + /** + * Response from Replicate file upload API. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record FileUploadResponse(@JsonProperty("id") String id, @JsonProperty("name") String name, + @JsonProperty("content_type") String contentType, @JsonProperty("size") Long size, + @JsonProperty("urls") FileUrls urls, @JsonProperty("created_at") String createdAt, + @JsonProperty("expires_at") String expiresAt) { + } + + /** + * URLs for accessing an uploaded file. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record FileUrls(@JsonProperty("get") String get) { + } + + /** + * Builder to Construct a {@link ReplicateApi} instance + */ + public static final class Builder { + + private String baseUrl = DEFAULT_BASE_URL; + + private ApiKey apiKey; + + private RestClient.Builder restClientBuilder = RestClient.builder(); + + private WebClient.Builder webClientBuilder = WebClient.builder(); + + private ResponseErrorHandler responseErrorHandler = RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER; + + public Builder baseUrl(String baseUrl) { + Assert.hasText(baseUrl, "baseUrl cannot be empty"); + this.baseUrl = baseUrl; + return this; + } + + public Builder apiKey(String apiKey) { + Assert.notNull(apiKey, "ApiKey cannot be null"); + this.apiKey = new SimpleApiKey(apiKey); + return this; + } + + public Builder restClientBuilder(RestClient.Builder restClientBuilder) { + Assert.notNull(restClientBuilder, "restClientBuilder cannot be null"); + this.restClientBuilder = restClientBuilder; + return this; + } + + public Builder webClientBuilder(WebClient.Builder webClientBuilder) { + Assert.notNull(webClientBuilder, "webClientBuilder cannot be null"); + this.webClientBuilder = webClientBuilder; + return this; + } + + public Builder responseErrorHandler(ResponseErrorHandler responseErrorHandler) { + Assert.notNull(responseErrorHandler, "responseErrorHandler cannot be null"); + this.responseErrorHandler = responseErrorHandler; + return this; + } + + public ReplicateApi build() { + Assert.notNull(this.apiKey, "cannot construct instance without apiKey"); + return new ReplicateApi(this.baseUrl, this.apiKey, this.restClientBuilder, this.webClientBuilder, + this.responseErrorHandler); + } + + } + + /** + * Exception thrown when a Replicate prediction fails or times out. + */ + public static class ReplicatePredictionException extends RuntimeException { + + public ReplicatePredictionException(String message) { + super(message); + } + + } + + /** + * Exception thrown when a Replicate prediction has not finished yet. Used for + * RetryTemplate. + */ + public static class ReplicatePredictionNotFinishedException extends RuntimeException { + + public ReplicatePredictionNotFinishedException(String message) { + super(message); + } + + } + +} diff --git a/models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/ReplicateChatModelIT.java b/models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/ReplicateChatModelIT.java new file mode 100644 index 00000000000..23108e94b67 --- /dev/null +++ b/models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/ReplicateChatModelIT.java @@ -0,0 +1,131 @@ +/* + * 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.replicate; + +import java.util.List; +import java.util.Objects; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import reactor.core.publisher.Flux; + +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.metadata.ChatResponseMetadata; +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.Prompt; +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 ReplicateChatModel}. + * + * @author Rene Maierhofer + */ +@SpringBootTest(classes = ReplicateTestConfiguration.class) +@EnabledIfEnvironmentVariable(named = "REPLICATE_API_TOKEN", matches = ".+") +class ReplicateChatModelIT { + + @Autowired + private ReplicateChatModel chatModel; + + @Test + void testSimpleCall() { + String userMessage = "What is the capital of France? Answer in one word."; + ChatResponse response = this.chatModel.call(new Prompt(userMessage)); + + assertThat(response).isNotNull(); + assertThat(response.getResults()).isNotEmpty(); + + Generation generation = response.getResult(); + assertThat(generation).isNotNull(); + Assertions.assertNotNull(generation.getOutput().getText()); + Assertions.assertFalse(generation.getOutput().getText().isEmpty()); + assertThat(generation.getOutput().getText().toLowerCase()).contains("paris"); + + ChatResponseMetadata metadata = response.getMetadata(); + assertThat(metadata).isNotNull(); + assertThat(metadata.getId()).isNotNull(); + assertThat(metadata.getModel()).isNotNull(); + assertThat(metadata.getUsage()).isNotNull(); + assertThat(metadata.getUsage().getPromptTokens()).isGreaterThanOrEqualTo(0); + assertThat(metadata.getUsage().getCompletionTokens()).isGreaterThanOrEqualTo(0); + } + + @Test + void testStreamingCall() { + String userMessage = "Count from 1 to 500."; + Flux responseFlux = this.chatModel.stream(new Prompt(userMessage)); + List responses = responseFlux.collectList().block(); + assertThat(responses).isNotNull().isNotEmpty().hasSizeGreaterThan(1); + + responses.forEach(response -> { + assertThat(response.getResults()).isNotEmpty(); + assertThat(response.getResult().getOutput().getText()).isNotNull(); + }); + + List chunks = responses.stream() + .flatMap(chatResponse -> chatResponse.getResults().stream()) + .map(generation -> generation.getOutput().getText()) + .filter(Objects::nonNull) + .filter(text -> !text.isEmpty()) + .toList(); + + assertThat(chunks).hasSizeGreaterThan(1); + + String fullContent = String.join("", chunks); + assertThat(fullContent).isNotEmpty(); + + boolean hasMetadata = responses.stream().anyMatch(response -> response.getMetadata().getId() != null); + assertThat(hasMetadata).isTrue(); + } + + @Test + void testCallWithOptions() { + int maxTokens = 10; + ReplicateChatOptions options = ReplicateChatOptions.builder() + .model("meta/meta-llama-3-8b-instruct") + .withParameter("temperature", 0.8) + .withParameter("max_tokens", maxTokens) + .build(); + + Prompt prompt = new Prompt("Write a very long poem.", options); + ChatResponse response = this.chatModel.call(prompt); + + assertThat(response).isNotNull(); + assertThat(response.getResults()).isNotEmpty(); + assertThat(response.getResult().getOutput().getText()).isNotEmpty(); + ChatResponseMetadata metadata = response.getMetadata(); + Usage usage = metadata.getUsage(); + assertThat(usage.getCompletionTokens()).isLessThanOrEqualTo(maxTokens); + } + + @Test + void testMultiTurnConversation_shouldNotWork() { + UserMessage userMessage1 = new UserMessage("My favorite color is blue."); + AssistantMessage assistantMessage = new AssistantMessage("Noted!"); + UserMessage userMessage2 = new UserMessage("What is my favorite color?"); + Prompt prompt = new Prompt(List.of(userMessage1, assistantMessage, userMessage2)); + Assertions.assertThrows(AssertionError.class, () -> this.chatModel.call(prompt)); + } + +} diff --git a/models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/ReplicateChatOptionsTests.java b/models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/ReplicateChatOptionsTests.java new file mode 100644 index 00000000000..cc60a098581 --- /dev/null +++ b/models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/ReplicateChatOptionsTests.java @@ -0,0 +1,123 @@ +/* + * 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.replicate; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link ReplicateChatOptions}. + * + * @author Rene Maierhofer + */ +class ReplicateChatOptionsTests { + + @Test + void testBuilderWithAllFields() { + Map inputParams = new HashMap<>(); + inputParams.put("temperature", 0.7); + inputParams.put("max_tokens", 100); + + List webhookEvents = Arrays.asList("start", "completed"); + + ReplicateChatOptions options = ReplicateChatOptions.builder() + .model("meta/llama-3-8b-instruct") + .version("1234abcd") + .withParameters(inputParams) + .webhook("https://127.0.0.1/webhook") + .webhookEventsFilter(webhookEvents) + .build(); + + assertThat(options).extracting("model", "version", "webhook") + .containsExactly("meta/llama-3-8b-instruct", "1234abcd", "https://127.0.0.1/webhook"); + + assertThat(options.getInput()).containsEntry("temperature", 0.7).containsEntry("max_tokens", 100); + + assertThat(options.getWebhookEventsFilter()).containsExactly("start", "completed"); + } + + @Test + void testWithParameter() { + ReplicateChatOptions options = ReplicateChatOptions.builder().model("test-model").build(); + + options.withParameter("temperature", 0.8); + options.withParameter("max_tokens", 200); + + assertThat(options.getInput()).hasSize(2).containsEntry("temperature", 0.8).containsEntry("max_tokens", 200); + } + + @Test + void testWithParametersMap() { + Map params = new HashMap<>(); + params.put("param1", "value1"); + params.put("param2", 42); + + ReplicateChatOptions options = ReplicateChatOptions.builder().model("test-model").build(); + + options.withParameters(params); + + assertThat(options.getInput()).hasSize(2).containsEntry("param1", "value1").containsEntry("param2", 42); + } + + @Test + void testFromOptions() { + Map inputParams = new HashMap<>(); + inputParams.put("temperature", 0.7); + + List webhookEvents = Arrays.asList("start", "completed"); + + ReplicateChatOptions original = ReplicateChatOptions.builder() + .model("meta/llama-3-8b-instruct") + .version("1234abcd") + .withParameters(inputParams) + .webhook("https://127.0.0.1/webhook") + .webhookEventsFilter(webhookEvents) + .build(); + + ReplicateChatOptions copy = ReplicateChatOptions.fromOptions(original); + + assertThat(copy).isNotSameAs(original); + assertThat(copy.getModel()).isEqualTo(original.getModel()); + assertThat(copy.getVersion()).isEqualTo(original.getVersion()); + assertThat(copy.getWebhook()).isEqualTo(original.getWebhook()); + assertThat(copy.getWebhookEventsFilter()).isEqualTo(original.getWebhookEventsFilter()); + assertThat(copy.getInput()).isNotSameAs(original.getInput()).isEqualTo(original.getInput()); + } + + @Test + void testSetInputConvertsStringValues() { + ReplicateChatOptions options = new ReplicateChatOptions(); + + Map input = new HashMap<>(); + input.put("temperature", "0.7"); + input.put("maxTokens", "100"); + input.put("enabled", "true"); + + options.setInput(input); + + assertThat(options.getInput().get("temperature")).isInstanceOf(Double.class).isEqualTo(0.7); + assertThat(options.getInput().get("maxTokens")).isInstanceOf(Integer.class).isEqualTo(100); + assertThat(options.getInput().get("enabled")).isInstanceOf(Boolean.class).isEqualTo(true); + } + +} diff --git a/models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/ReplicateMediaModelIT.java b/models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/ReplicateMediaModelIT.java new file mode 100644 index 00000000000..c8f4dc4ffce --- /dev/null +++ b/models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/ReplicateMediaModelIT.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.replicate; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import org.springframework.ai.replicate.ReplicateMediaModel.MediaResponse; +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 ReplicateMediaModel}. + * + * @author Rene Maierhofer + */ +@SpringBootTest(classes = ReplicateTestConfiguration.class) +@EnabledIfEnvironmentVariable(named = "REPLICATE_API_TOKEN", matches = ".+") +class ReplicateMediaModelIT { + + @Autowired + private ReplicateMediaModel mediaModel; + + @Test + void testGenerateMultipleImages() { + ReplicateOptions options = ReplicateOptions.builder() + .model("black-forest-labs/flux-schnell") + .withParameter("prompt", "a cat sitting on a laptop") + .withParameter("num_outputs", 2) + .build(); + + MediaResponse response = this.mediaModel.generate(options); + + assertThat(response).isNotNull(); + assertThat(response.getUris()).isNotEmpty(); + response.getUris().forEach(uri -> { + assertThat(uri).isNotEmpty(); + assertThat(uri).startsWith("http"); + }); + assertThat(response.getPredictionResponse()).isNotNull(); + assertThat(response.getPredictionResponse().id()).isNotNull(); + assertThat(response.getPredictionResponse().createdAt()).isNotNull(); + assertThat(response.getPredictionResponse().status()).isNotNull(); + assertThat(response.getPredictionResponse().model()).contains("black-forest-labs/flux-schnell"); + } + + @Test + void testGenerateWithDefaultOptions() { + ReplicateOptions options = ReplicateOptions.builder().withParameter("prompt", "a serene lake").build(); + + MediaResponse response = this.mediaModel.generate(options); + + assertThat(response).isNotNull(); + assertThat(response.getUris()).isNotEmpty(); + } + +} diff --git a/models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/ReplicateOptionsTests.java b/models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/ReplicateOptionsTests.java new file mode 100644 index 00000000000..9b41d3a3a64 --- /dev/null +++ b/models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/ReplicateOptionsTests.java @@ -0,0 +1,123 @@ +/* + * 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.replicate; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link ReplicateOptions}. + * + * @author Rene Maierhofer + */ +class ReplicateOptionsTests { + + @Test + void testBuilderWithAllFields() { + Map inputParams = new HashMap<>(); + inputParams.put("temperature", 0.7); + inputParams.put("max_tokens", 100); + + List webhookEvents = Arrays.asList("start", "completed"); + + ReplicateOptions options = ReplicateOptions.builder() + .model("meta/llama-3-8b-instruct") + .version("1234abcd") + .withParameters(inputParams) + .webhook("https://example.com/webhook") + .webhookEventsFilter(webhookEvents) + .build(); + + assertThat(options).extracting("model", "version", "webhook") + .containsExactly("meta/llama-3-8b-instruct", "1234abcd", "https://example.com/webhook"); + + assertThat(options.getInput()).containsEntry("temperature", 0.7).containsEntry("max_tokens", 100); + + assertThat(options.getWebhookEventsFilter()).containsExactly("start", "completed"); + } + + @Test + void testWithParameter() { + ReplicateOptions options = ReplicateOptions.builder().model("test-model").build(); + + options.withParameter("temperature", 0.8); + options.withParameter("max_tokens", 200); + + assertThat(options.getInput()).hasSize(2).containsEntry("temperature", 0.8).containsEntry("max_tokens", 200); + } + + @Test + void testWithParametersMap() { + Map params = new HashMap<>(); + params.put("param1", "value1"); + params.put("param2", 42); + + ReplicateOptions options = ReplicateOptions.builder().model("test-model").build(); + + options.withParameters(params); + + assertThat(options.getInput()).hasSize(2).containsEntry("param1", "value1").containsEntry("param2", 42); + } + + @Test + void testFromOptions() { + Map inputParams = new HashMap<>(); + inputParams.put("temperature", 0.7); + + List webhookEvents = Arrays.asList("start", "completed"); + + ReplicateOptions original = ReplicateOptions.builder() + .model("meta/llama-3-8b-instruct") + .version("1234abcd") + .withParameters(inputParams) + .webhook("https://example.com/webhook") + .webhookEventsFilter(webhookEvents) + .build(); + + ReplicateOptions copy = ReplicateOptions.fromOptions(original); + + assertThat(copy).isNotSameAs(original); + assertThat(copy.getModel()).isEqualTo(original.getModel()); + assertThat(copy.getVersion()).isEqualTo(original.getVersion()); + assertThat(copy.getWebhook()).isEqualTo(original.getWebhook()); + assertThat(copy.getWebhookEventsFilter()).isEqualTo(original.getWebhookEventsFilter()); + assertThat(copy.getInput()).isNotSameAs(original.getInput()).isEqualTo(original.getInput()); + } + + @Test + void testSetInputConvertsStringValues() { + ReplicateOptions options = new ReplicateOptions(); + + Map input = new HashMap<>(); + input.put("temperature", "0.7"); + input.put("maxTokens", "100"); + input.put("enabled", "true"); + + options.setInput(input); + + assertThat(options.getInput().get("temperature")).isInstanceOf(Double.class).isEqualTo(0.7); + assertThat(options.getInput().get("maxTokens")).isInstanceOf(Integer.class).isEqualTo(100); + assertThat(options.getInput().get("enabled")).isInstanceOf(Boolean.class).isEqualTo(true); + } + +} diff --git a/models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/ReplicateOptionsUtilsTests.java b/models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/ReplicateOptionsUtilsTests.java new file mode 100644 index 00000000000..9596394e59e --- /dev/null +++ b/models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/ReplicateOptionsUtilsTests.java @@ -0,0 +1,89 @@ +/* + * 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.replicate; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link ReplicateOptionsUtils}. + * + * @author Rene Maierhofer + */ +class ReplicateOptionsUtilsTests { + + @Test + void testConvertValueWithBoolean() { + assertThat(ReplicateOptionsUtils.convertValue("true")).isInstanceOf(Boolean.class).isEqualTo(true); + assertThat(ReplicateOptionsUtils.convertValue("false")).isInstanceOf(Boolean.class).isEqualTo(false); + assertThat(ReplicateOptionsUtils.convertValue("TRUE")).isEqualTo(true); + assertThat(ReplicateOptionsUtils.convertValue("False")).isEqualTo(false); + assertThat(ReplicateOptionsUtils.convertValue("TrUe")).isEqualTo(true); + } + + @Test + void testConvertValueNumeric() { + assertThat(ReplicateOptionsUtils.convertValue("42")).isInstanceOf(Integer.class).isEqualTo(42); + assertThat(ReplicateOptionsUtils.convertValue("3.14")).isInstanceOf(Double.class).isEqualTo(3.14); + assertThat(ReplicateOptionsUtils.convertValue("1.5e10")).isInstanceOf(Double.class).isEqualTo(1.5E10); + } + + @Test + void testConvertValueWithPlainString() { + assertThat(ReplicateOptionsUtils.convertValue("hello world")).isInstanceOf(String.class) + .isEqualTo("hello world"); + } + + @Test + void testConvertValueWithNonStringValue() { + Integer intValue = 100; + Object result = ReplicateOptionsUtils.convertValue(intValue); + assertThat(result).isSameAs(intValue); + + Double doubleValue = 5.5; + result = ReplicateOptionsUtils.convertValue(doubleValue); + assertThat(result).isSameAs(doubleValue); + + Boolean boolValue = true; + result = ReplicateOptionsUtils.convertValue(boolValue); + assertThat(result).isSameAs(boolValue); + } + + @Test + void testConvertMapValuesWithMixedTypes() { + Map source = new HashMap<>(); + source.put("temperature", "0.7"); + source.put("maxTokens", "100"); + source.put("enabled", "true"); + source.put("model", "meta/llama-3"); + source.put("existingInt", 42); + + Map result = ReplicateOptionsUtils.convertMapValues(source); + + assertThat(result).hasSize(5); + assertThat(result.get("temperature")).isInstanceOf(Double.class).isEqualTo(0.7); + assertThat(result.get("maxTokens")).isInstanceOf(Integer.class).isEqualTo(100); + assertThat(result.get("enabled")).isInstanceOf(Boolean.class).isEqualTo(true); + assertThat(result.get("model")).isInstanceOf(String.class).isEqualTo("meta/llama-3"); + assertThat(result.get("existingInt")).isInstanceOf(Integer.class).isEqualTo(42); + } + +} diff --git a/models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/ReplicateStringModelIT.java b/models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/ReplicateStringModelIT.java new file mode 100644 index 00000000000..d2d5d3f1329 --- /dev/null +++ b/models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/ReplicateStringModelIT.java @@ -0,0 +1,102 @@ +/* + * 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.replicate; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Base64; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import org.springframework.ai.replicate.ReplicateStringModel.StringResponse; +import org.springframework.ai.replicate.api.ReplicateApi; +import org.springframework.ai.replicate.api.ReplicateApi.FileUploadResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.io.FileSystemResource; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link ReplicateStringModel}. + * + * @author Rene Maierhofer + */ +@SpringBootTest(classes = ReplicateTestConfiguration.class) +@EnabledIfEnvironmentVariable(named = "REPLICATE_API_TOKEN", matches = ".+") +class ReplicateStringModelIT { + + @Autowired + private ReplicateStringModel stringModel; + + @Autowired + private ReplicateApi replicateApi; + + @Test + void testClassifyImageWithFileUpload() { + Path imagePath = Paths.get("src/test/resources/test-image.jpg"); + FileSystemResource fileResource = new FileSystemResource(imagePath); + + FileUploadResponse uploadResponse = this.replicateApi.uploadFile(fileResource, "test-image.jpg"); + + assertThat(uploadResponse).isNotNull(); + assertThat(uploadResponse.urls()).isNotNull(); + assertThat(uploadResponse.urls().get()).isNotEmpty(); + + String imageUrl = uploadResponse.urls().get(); + + ReplicateOptions options = ReplicateOptions.builder() + .model("falcons-ai/nsfw_image_detection") + .withParameter("image", imageUrl) + .build(); + + StringResponse response = this.stringModel.generate(options); + + // Validate output + assertThat(response).isNotNull(); + assertThat(response.getOutput()).isNotNull().isInstanceOf(String.class).isNotEmpty(); + assertThat(response.getOutput().toLowerCase()).isEqualTo("normal"); + + // Validate metadata + assertThat(response.getPredictionResponse()).isNotNull(); + assertThat(response.getPredictionResponse().id()).isNotNull(); + } + + @Test + void testClassifyImageWithBase64() throws IOException { + Path imagePath = Paths.get("src/test/resources/test-image.jpg"); + byte[] imageBytes = Files.readAllBytes(imagePath); + String base64Image = "data:application/octet-stream;base64," + Base64.getEncoder().encodeToString(imageBytes); + + ReplicateOptions options = ReplicateOptions.builder() + .model("falcons-ai/nsfw_image_detection") + .withParameter("image", base64Image) + .build(); + + StringResponse response = this.stringModel.generate(options); + + assertThat(response).isNotNull(); + assertThat(response.getOutput()).isNotNull().isInstanceOf(String.class).isNotEmpty(); + assertThat(response.getOutput().toLowerCase()).isEqualTo("normal"); + assertThat(response.getPredictionResponse()).isNotNull(); + assertThat(response.getPredictionResponse().id()).isNotNull(); + } + +} diff --git a/models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/ReplicateStructuredModelIT.java b/models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/ReplicateStructuredModelIT.java new file mode 100644 index 00000000000..e2b9c2b0ae2 --- /dev/null +++ b/models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/ReplicateStructuredModelIT.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.replicate; + +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import org.springframework.ai.replicate.ReplicateStructuredModel.StructuredResponse; +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 ReplicateStructuredModel}. + * + * @author Rene Maierhofer + */ +@SpringBootTest(classes = ReplicateTestConfiguration.class) +@EnabledIfEnvironmentVariable(named = "REPLICATE_API_TOKEN", matches = ".+") +class ReplicateStructuredModelIT { + + @Autowired + private ReplicateStructuredModel structuredModel; + + @Test + void testGenerateEmbeddingsWithMetadata() { + ReplicateOptions options = ReplicateOptions.builder() + .model("openai/clip") + .withParameter("text", "spring ai") + .build(); + + StructuredResponse response = this.structuredModel.generate(options); + + assertThat(response).isNotNull(); + assertThat(response.getOutput()).isNotNull().isInstanceOf(Map.class); + Map output = response.getOutput(); + assertThat(output).isNotEmpty(); + assertThat(response.getPredictionResponse()).isNotNull(); + assertThat(response.getPredictionResponse().id()).isNotNull(); + assertThat(response.getPredictionResponse().status()).isNotNull(); + assertThat(response.getPredictionResponse().model()).contains("openai/clip"); + assertThat(response.getPredictionResponse().createdAt()).isNotNull(); + assertThat(response.getPredictionResponse().output()).isNotNull(); + } + + @Test + void testWithDefaultOptions() { + ReplicateOptions options = ReplicateOptions.builder().withParameter("text", "machine learning").build(); + + StructuredResponse response = this.structuredModel.generate(options); + + assertThat(response).isNotNull(); + assertThat(response.getOutput()).isNotNull(); + } + +} diff --git a/models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/ReplicateTestConfiguration.java b/models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/ReplicateTestConfiguration.java new file mode 100644 index 00000000000..bd43c454071 --- /dev/null +++ b/models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/ReplicateTestConfiguration.java @@ -0,0 +1,92 @@ +/* + * 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.replicate; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.ai.replicate.api.ReplicateApi; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.util.StringUtils; + +/** + * Test configuration for Replicate integration tests. + * + * @author Rene Maierhofer + */ +@SpringBootConfiguration +public class ReplicateTestConfiguration { + + @Bean + public ReplicateApi replicateApi() { + return ReplicateApi.builder().apiKey(getApiKey()).build(); + } + + @Bean + public ReplicateChatModel replicateChatModel(ReplicateApi api, ObservationRegistry observationRegistry) { + return ReplicateChatModel.builder() + .replicateApi(api) + .observationRegistry(observationRegistry) + .defaultOptions(ReplicateChatOptions.builder().model("meta/meta-llama-3-8b-instruct").build()) + .build(); + } + + @Bean + public ReplicateMediaModel replicateMediaModel(ReplicateApi api) { + return ReplicateMediaModel.builder() + .replicateApi(api) + .defaultOptions(ReplicateOptions.builder().model("black-forest-labs/flux-schnell").build()) + .build(); + } + + @Bean + public ReplicateStringModel replicateStringModel(ReplicateApi api) { + return ReplicateStringModel.builder() + .replicateApi(api) + .defaultOptions(ReplicateOptions.builder().model("falcons-ai/nsfw_image_detection").build()) + .build(); + } + + @Bean + public ReplicateStructuredModel replicateStructuredModel(ReplicateApi api) { + return ReplicateStructuredModel.builder() + .replicateApi(api) + .defaultOptions(ReplicateOptions.builder().model("openai/clip").build()) + .build(); + } + + @Bean + public ObservationRegistry observationRegistry() { + return ObservationRegistry.create(); + } + + @Bean + public ObjectMapper objectMapper() { + return new ObjectMapper(); + } + + private String getApiKey() { + String apiKey = System.getenv("REPLICATE_API_TOKEN"); + if (!StringUtils.hasText(apiKey)) { + throw new IllegalArgumentException( + "You must provide a Replicate API token. Please set the REPLICATE_API_TOKEN environment variable."); + } + return apiKey; + } + +} diff --git a/models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/api/ReplicateApiBuilderTests.java b/models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/api/ReplicateApiBuilderTests.java new file mode 100644 index 00000000000..efa59b3589a --- /dev/null +++ b/models/spring-ai-replicate/src/test/java/org/springframework/ai/replicate/api/ReplicateApiBuilderTests.java @@ -0,0 +1,84 @@ +/* + * 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.replicate.api; + +import org.junit.jupiter.api.Test; + +import org.springframework.web.client.ResponseErrorHandler; +import org.springframework.web.client.RestClient; +import org.springframework.web.reactive.function.client.WebClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +/** + * Unit tests for {@link ReplicateApi.Builder}. + * + * @author Rene Maierhofer + */ +class ReplicateApiBuilderTests { + + private static final String TEST_API_KEY = "someKey"; + + private static final String TEST_BASE_URL = "http://127.0.0.1"; + + @Test + void testBuilderWithOptions() { + RestClient.Builder restClientBuilder = RestClient.builder(); + WebClient.Builder webClientBuilder = WebClient.builder(); + ResponseErrorHandler errorHandler = mock(ResponseErrorHandler.class); + + ReplicateApi api = ReplicateApi.builder() + .apiKey(TEST_API_KEY) + .baseUrl(TEST_BASE_URL) + .restClientBuilder(restClientBuilder) + .webClientBuilder(webClientBuilder) + .responseErrorHandler(errorHandler) + .build(); + + assertThat(api).isNotNull(); + } + + @Test + void testBuilderWithoutApiKeyThrowsException() { + ReplicateApi.Builder builder = ReplicateApi.builder(); + assertThatThrownBy(builder::build).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("apiKey"); + } + + @Test + void testBuilderWithNullApiKeyThrowsException() { + ReplicateApi.Builder builder = ReplicateApi.builder(); + assertThatThrownBy(() -> builder.apiKey(null)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("ApiKey cannot be null"); + } + + @Test + void testBuilderWithEmptyBaseUrlThrowsException() { + ReplicateApi.Builder builder = ReplicateApi.builder(); + assertThatThrownBy(() -> builder.baseUrl("")).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("baseUrl cannot be empty"); + } + + @Test + void testBuilderWithNullBaseUrlThrowsException() { + ReplicateApi.Builder builder = ReplicateApi.builder(); + assertThatThrownBy(() -> builder.baseUrl(null)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("baseUrl cannot be empty"); + } + +} diff --git a/models/spring-ai-replicate/src/test/resources/test-image.jpg b/models/spring-ai-replicate/src/test/resources/test-image.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a297f1869f7f3d95f02b06d95a81a55d69e2958a GIT binary patch literal 95670 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 T#f0`A6Pj@Kv=iiPRb~I#tIK-- literal 0 HcmV?d00001 diff --git a/pom.xml b/pom.xml index f3619e8a8c0..89bae4dc5bd 100644 --- a/pom.xml +++ b/pom.xml @@ -116,6 +116,7 @@ auto-configurations/models/spring-ai-autoconfigure-model-google-genai auto-configurations/models/spring-ai-autoconfigure-model-zhipuai auto-configurations/models/spring-ai-autoconfigure-model-deepseek + auto-configurations/models/spring-ai-autoconfigure-model-replicate auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-httpclient @@ -187,6 +188,7 @@ models/spring-ai-google-genai-embedding models/spring-ai-zhipuai models/spring-ai-deepseek + models/spring-ai-replicate spring-ai-spring-boot-starters/spring-ai-starter-model-anthropic spring-ai-spring-boot-starters/spring-ai-starter-model-azure-openai 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..461b42a2a9b 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 Replicate + */ + REPLICATE("replicate"), + /** * AI system provided by Spring AI. */