diff --git a/build.gradle b/build.gradle index ab31ac4..cfe2d5f 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ buildscript { ext { jmockitVersion = "1.49" springBootVersion = "3.4.3" + springAiVersion = "1.0.0" } } diff --git a/gradle.properties b/gradle.properties index 57d1c9f..5fee075 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ org.gradle.parallel=true group=io.pivotal.cfenv -version=3.4.0-SNAPSHOT +version=3.5.0-SNAPSHOT onlyShowStandardStreamsOnTestFailure=false diff --git a/java-cfenv-boot-tanzu-genai/README.md b/java-cfenv-boot-tanzu-genai/README.md new file mode 100644 index 0000000..dd8bb6e --- /dev/null +++ b/java-cfenv-boot-tanzu-genai/README.md @@ -0,0 +1,21 @@ +# Tanzu GenAI support + +This library is for use when accessing a Tanzu GenAI tile (version >= 10.2) configured plan with CF. This library uses the `VCAP_SERVICES` environment data to set properties that will enable a GenAILocator. + +## Spring Applications + +Spring Applications can use this library to auto-configure a GenAILocator that can be used to determine which models/mcp servers are available, what capabilities they support and a method of accessing them. + +This service provides the following properties to your spring application: + +| Property Name | Value | +|--------------------------|---------------------------------| +| genai.locator.config-url | config_url (from VCAP_SERVICES) | +| genai.locator.api-base | api_base (from VCAP_SERVICES) | +| genai.locator.api-key | api_key (from VCAP_SERVICES) | + +Please see the Sample Apps below for more information. + +### Sample Apps + +Sample apps using this library are available at TODO. \ No newline at end of file diff --git a/java-cfenv-boot-tanzu-genai/build.gradle b/java-cfenv-boot-tanzu-genai/build.gradle new file mode 100644 index 0000000..66458dc --- /dev/null +++ b/java-cfenv-boot-tanzu-genai/build.gradle @@ -0,0 +1,25 @@ +plugins { + id 'io.pivotal.cfenv.java-conventions' +} + +description = 'Java CF Env Tanzu GenAI' + +dependencies { + api project(':java-cfenv-boot') + api "org.springframework.ai:spring-ai-openai:${springAiVersion}" + implementation("org.springframework.boot:spring-boot-autoconfigure:${springBootVersion}") + + testImplementation project(':java-cfenv-test-support') + testImplementation "junit:junit" + testImplementation "org.jmockit:jmockit:${jmockitVersion}" + + testRuntimeOnly('org.junit.vintage:junit-vintage-engine') { + exclude group: 'org.hamcrest', module: 'hamcrest-core' + } +} + +tasks.named('jar') { + manifest { + attributes 'Automatic-Module-Name': 'io.pivotal.cfenv.boot.genai' + } +} diff --git a/java-cfenv-boot-tanzu-genai/src/main/java/io/pivotal/cfenv/boot/genai/CfGenaiProcessor.java b/java-cfenv-boot-tanzu-genai/src/main/java/io/pivotal/cfenv/boot/genai/CfGenaiProcessor.java new file mode 100644 index 0000000..090707c --- /dev/null +++ b/java-cfenv-boot-tanzu-genai/src/main/java/io/pivotal/cfenv/boot/genai/CfGenaiProcessor.java @@ -0,0 +1,55 @@ +/* + * Copyright 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 io.pivotal.cfenv.boot.genai; + +import java.util.Map; + +import io.pivotal.cfenv.core.CfCredentials; +import io.pivotal.cfenv.core.CfService; +import io.pivotal.cfenv.spring.boot.CfEnvProcessor; +import io.pivotal.cfenv.spring.boot.CfEnvProcessorProperties; + +/** + * Retrieve GenAI on Tanzu Platform properties from {@link CfCredentials} and + * set {@literal genai.locator.*} Boot properties. + * + * @author Gareth Evans + **/ +public class CfGenaiProcessor implements CfEnvProcessor { + + @Override + public boolean accept(CfService service) { + boolean isGenAIService = service.existsByTagIgnoreCase("genai") || service.existsByLabelStartsWith("genai"); + // we only want to process service instances that are generated from Tanzu Platform 10.2 or later + return (isGenAIService && service.getCredentials().getMap().containsKey("endpoint")); + } + + @Override + public void process(CfCredentials cfCredentials, Map properties) { + Map endpoint = (Map)cfCredentials.getMap().get("endpoint"); + + properties.put("genai.locator.config-url", endpoint.get("config_url")); + properties.put("genai.locator.api-key", endpoint.get("api_key")); + properties.put("genai.locator.api-base", endpoint.get( "api_base")); + } + + @Override + public CfEnvProcessorProperties getProperties() { + return CfEnvProcessorProperties.builder() + .propertyPrefixes("genai.locator") + .serviceName("Tanzu GenAI Locator").build(); + } +} diff --git a/java-cfenv-boot-tanzu-genai/src/main/java/io/pivotal/cfenv/boot/genai/DefaultGenaiLocator.java b/java-cfenv-boot-tanzu-genai/src/main/java/io/pivotal/cfenv/boot/genai/DefaultGenaiLocator.java new file mode 100644 index 0000000..5f5cf92 --- /dev/null +++ b/java-cfenv-boot-tanzu-genai/src/main/java/io/pivotal/cfenv/boot/genai/DefaultGenaiLocator.java @@ -0,0 +1,319 @@ +/* + * Copyright 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 io.pivotal.cfenv.boot.genai; + +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.document.MetadataMode; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.ai.openai.OpenAiEmbeddingModel; +import org.springframework.ai.openai.OpenAiEmbeddingOptions; +import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.web.client.RestClient; + +/** + * Locates available models and mcp servers from ai-servers config endpoint + * + * @author Gareth Evans + **/ +public class DefaultGenaiLocator implements GenaiLocator { + + private static final Logger LOGGER = LoggerFactory.getLogger(DefaultGenaiLocator.class); + + private final String configUrl; + private final String apiKey; + private final String apiBase; + private final RestClient.Builder builder; + + public DefaultGenaiLocator(RestClient.Builder builder, String configUrl, String apiKey, String apiBase) { + this.builder = builder; + this.configUrl = configUrl; + this.apiKey = apiKey; + this.apiBase = apiBase; + } + + @Override + public List getModelNames() { + return getModelNamesByCapability(null); + } + + @Override + public List getModelNamesByCapability(String capability) { + return getModelNamesByCapabilityAndLabels(capability, Map.of()); + } + + @Override + public List getModelNamesByLabels(Map labels) { + return getModelNamesByCapabilityAndLabels(null, labels); + } + + @Override + public List getModelNamesByCapabilityAndLabels( + String capability, Map labels) { + List models = getAllModelConnectivityDetails(); + + return models.stream() + .filter(filterModelConnectivityOnCapability(capability)) + .filter(filterModelConnectivityOnLabels(labels)) + .map(a -> a.name) + .toList(); + } + + @Override + public ChatModel getChatModelByName(String name) { + List models = getAllModelConnectivityDetails(); + + return models.stream() + .filter(filterModelConnectivityOnCapability("CHAT")) + .filter(c -> c.name().equals(name)) + .map(DefaultGenaiLocator::createChatModel) + .findFirst() + .orElseThrow( + () -> new RuntimeException("Unable to find chat model with name '" + name + "'")); + } + + @Override + public List getChatModelsByLabels(Map labels) { + List models = getAllModelConnectivityDetails(); + + return models.stream() + .filter(filterModelConnectivityOnCapability("CHAT")) + .filter(filterModelConnectivityOnLabels(labels)) + .map(DefaultGenaiLocator::createChatModel) + .toList(); + } + + @Override + public ChatModel getFirstAvailableChatModel() { + List models = getAllModelConnectivityDetails(); + + return models.stream() + .filter(filterModelConnectivityOnCapability("CHAT")) + .map(DefaultGenaiLocator::createChatModel) + .findFirst() + .orElseThrow(() -> new RuntimeException("Unable to find first chat model")); + } + + @Override + public ChatModel getFirstAvailableChatModelByLabels(Map labels) { + List models = getAllModelConnectivityDetails(); + + return models.stream() + .filter(filterModelConnectivityOnCapability("CHAT")) + .filter(filterModelConnectivityOnLabels(labels)) + .map(DefaultGenaiLocator::createChatModel) + .findFirst() + .orElseThrow(() -> new RuntimeException("Unable to find first chat model")); + } + + @Override + public List getToolModelsByLabels(Map labels) { + List models = getAllModelConnectivityDetails(); + + return models.stream() + .filter(filterModelConnectivityOnCapability("TOOLS")) + .filter(filterModelConnectivityOnLabels(labels)) + .map(DefaultGenaiLocator::createChatModel) + .toList(); + } + + @Override + public ChatModel getFirstAvailableToolModel() { + List models = getAllModelConnectivityDetails(); + + return models.stream() + .filter(filterModelConnectivityOnCapability("TOOLS")) + .map(DefaultGenaiLocator::createChatModel) + .findFirst() + .orElseThrow(() -> new RuntimeException("Unable to find first tool model")); + } + + @Override + public ChatModel getFirstAvailableToolModelByLabels(Map labels) { + List models = getAllModelConnectivityDetails(); + + return models.stream() + .filter(filterModelConnectivityOnCapability("TOOLS")) + .filter(filterModelConnectivityOnLabels(labels)) + .map(DefaultGenaiLocator::createChatModel) + .findFirst() + .orElseThrow(() -> new RuntimeException("Unable to find first tool model")); + } + + private static ChatModel createChatModel(ModelConnectivity c) { + OpenAiApi api = OpenAiApi.builder().apiKey(c.apiKey()).baseUrl(c.apiBase()).build(); + return OpenAiChatModel.builder() + .defaultOptions(OpenAiChatOptions.builder().model(c.name()).build()) + .openAiApi(api) + .build(); + } + + @Override + public EmbeddingModel getEmbeddingModelByName(String name) { + List models = getAllModelConnectivityDetails(); + + return models.stream() + .filter(filterModelConnectivityOnCapability("EMBEDDING")) + .filter(c -> c.name().equals(name)) + .map(DefaultGenaiLocator::createEmbeddingModel) + .findFirst() + .orElseThrow( + () -> new RuntimeException("Unable to find embedding model with name '" + name + "'")); + } + + @Override + public List getEmbeddingModelsByLabels(Map labels) { + List models = getAllModelConnectivityDetails(); + + return models.stream() + .filter(filterModelConnectivityOnCapability("EMBEDDING")) + .filter(filterModelConnectivityOnLabels(labels)) + .map(DefaultGenaiLocator::createEmbeddingModel) + .toList(); + } + + @Override + public EmbeddingModel getFirstAvailableEmbeddingModel() { + List models = getAllModelConnectivityDetails(); + + return models.stream() + .filter(filterModelConnectivityOnCapability("EMBEDDING")) + .map(DefaultGenaiLocator::createEmbeddingModel) + .findFirst() + .orElseThrow(() -> new RuntimeException("Unable to find first embedding model")); + } + + @Override + public EmbeddingModel getFirstAvailableEmbeddingModelByLabels(Map labels) { + List models = getAllModelConnectivityDetails(); + + return models.stream() + .filter(filterModelConnectivityOnCapability("EMBEDDING")) + .filter(filterModelConnectivityOnLabels(labels)) + .map(DefaultGenaiLocator::createEmbeddingModel) + .findFirst() + .orElseThrow(() -> new RuntimeException("Unable to find first embedding model")); + } + + private static EmbeddingModel createEmbeddingModel(ModelConnectivity c) { + OpenAiApi api = OpenAiApi.builder().apiKey(c.apiKey()).baseUrl(c.apiBase()).build(); + return new OpenAiEmbeddingModel( + api, MetadataMode.EMBED, OpenAiEmbeddingOptions.builder().model(c.name()).build()); + } + + @Override + public List getMcpServers() { + return getAllMcpConnectivityDetails(); + } + + private List getAllModelConnectivityDetails() { + ConfigEndpoint e = getEndpointConfig(); + return e.advertisedModels + .stream() + .map( a -> + new ModelConnectivity( + a.name(), + a.capabilities(), + a.labels(), + apiKey, + apiBase + e.wireFormat().toLowerCase()) + ) + .toList(); + } + + private List getAllMcpConnectivityDetails() { + return getEndpointConfig() + .advertisedMcpServers + .stream() + .map(m -> new McpConnectivity(m.url())) + .toList(); + } + + private ConfigEndpoint getEndpointConfig() { + RestClient client = builder.build(); + LOGGER.info("Retrieving config from url {}", configUrl); + return client + .get() + .uri(configUrl) + .header("Authorization", "Bearer " + apiKey) + .retrieve() + .body(ConfigEndpoint.class); + } + + private Predicate filterModelConnectivityOnLabels(Map labels) { + return modelConnectivity -> { + if (labels == null || labels.isEmpty()) { + return true; + } + + if (modelConnectivity.labels() == null) { + return false; + } + + return modelConnectivity.labels().entrySet().containsAll(labels.entrySet()); + }; + } + + private Predicate filterModelConnectivityOnCapability(String capability) { + return modelConnectivity -> { + if (capability == null || capability.isEmpty()) { + return true; + } + + if (modelConnectivity.capabilities() == null) { + return false; + } + + return modelConnectivity.capabilities().contains(capability); + }; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record ModelConnectivity( + String name, + List capabilities, + Map labels, + String apiKey, + String apiBase) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + private record ConfigEndpoint( + @JsonProperty("name") String name, + @JsonProperty("description") String description, + @JsonProperty("wireFormat") String wireFormat, + @JsonProperty("advertisedModels") List advertisedModels, + @JsonProperty("advertisedMcpServers") List advertisedMcpServers) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + private record ConfigAdvertisedModel( + @JsonProperty("name") String name, + @JsonProperty("description") String description, + @JsonProperty("capabilities") List capabilities, + @JsonProperty("labels") Map labels) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + private record ConfigAdvertisedMcpServer(@JsonProperty("url") String url) {} +} diff --git a/java-cfenv-boot-tanzu-genai/src/main/java/io/pivotal/cfenv/boot/genai/GenaiLocator.java b/java-cfenv-boot-tanzu-genai/src/main/java/io/pivotal/cfenv/boot/genai/GenaiLocator.java new file mode 100644 index 0000000..aa481a3 --- /dev/null +++ b/java-cfenv-boot-tanzu-genai/src/main/java/io/pivotal/cfenv/boot/genai/GenaiLocator.java @@ -0,0 +1,67 @@ +/* + * Copyright 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 io.pivotal.cfenv.boot.genai; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.embedding.EmbeddingModel; + +/** + * Locates available models and mcp servers from ai-servers config endpoint + * + * @author Gareth Evans + **/ +public interface GenaiLocator { + + List getModelNames(); + + List getModelNamesByCapability(String capability); + + List getModelNamesByLabels(Map labels); + + List getModelNamesByCapabilityAndLabels(String capability, Map labels); + + ChatModel getChatModelByName(String name); + + List getChatModelsByLabels(Map labels); + + ChatModel getFirstAvailableChatModel(); + + ChatModel getFirstAvailableChatModelByLabels(Map labels); + + List getToolModelsByLabels(Map labels); + + ChatModel getFirstAvailableToolModel(); + + ChatModel getFirstAvailableToolModelByLabels(Map labels); + + EmbeddingModel getEmbeddingModelByName(String name); + + List getEmbeddingModelsByLabels(Map labels); + + EmbeddingModel getFirstAvailableEmbeddingModel(); + + EmbeddingModel getFirstAvailableEmbeddingModelByLabels(Map labels); + + List getMcpServers(); + + @JsonIgnoreProperties(ignoreUnknown = true) + record McpConnectivity(String url) {} +} diff --git a/java-cfenv-boot-tanzu-genai/src/main/java/io/pivotal/cfenv/boot/genai/GenaiLocatorAutoConfiguration.java b/java-cfenv-boot-tanzu-genai/src/main/java/io/pivotal/cfenv/boot/genai/GenaiLocatorAutoConfiguration.java new file mode 100644 index 0000000..7349c8e --- /dev/null +++ b/java-cfenv-boot-tanzu-genai/src/main/java/io/pivotal/cfenv/boot/genai/GenaiLocatorAutoConfiguration.java @@ -0,0 +1,42 @@ +/* + * Copyright 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 io.pivotal.cfenv.boot.genai; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.web.client.RestClient; + +/** + * Auto configuration for the genai locator. + * + * @author Gareth Evans + **/ +@AutoConfiguration +@ConditionalOnProperty("genai.locator.config-url") +public class GenaiLocatorAutoConfiguration { + + @Bean + public GenaiLocator genaiLocator( + RestClient.Builder builder, + @Value("${genai.locator.config-url}") String configUrl, + @Value("${genai.locator.api-key}") String apiKey, + @Value("${genai.locator.api-base}") String apiBase + ) { + return new DefaultGenaiLocator(builder, configUrl, apiKey, apiBase); + } +} diff --git a/java-cfenv-boot-tanzu-genai/src/main/resources/META-INF/spring.factories b/java-cfenv-boot-tanzu-genai/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..654fd93 --- /dev/null +++ b/java-cfenv-boot-tanzu-genai/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +io.pivotal.cfenv.spring.boot.CfEnvProcessor=\ + io.pivotal.cfenv.boot.genai.CfGenaiProcessor diff --git a/java-cfenv-boot-tanzu-genai/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/java-cfenv-boot-tanzu-genai/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..32cb06c --- /dev/null +++ b/java-cfenv-boot-tanzu-genai/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +io.pivotal.cfenv.boot.genai.GenaiLocatorAutoConfiguration + diff --git a/java-cfenv-boot-tanzu-genai/src/test/java/io/pivotal/cfenv/boot/genai/BootIntegrationTest.java b/java-cfenv-boot-tanzu-genai/src/test/java/io/pivotal/cfenv/boot/genai/BootIntegrationTest.java new file mode 100644 index 0000000..e735302 --- /dev/null +++ b/java-cfenv-boot-tanzu-genai/src/test/java/io/pivotal/cfenv/boot/genai/BootIntegrationTest.java @@ -0,0 +1,62 @@ +/* + * Copyright 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 io.pivotal.cfenv.boot.genai; + +import mockit.MockUp; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.support.SpringFactoriesLoader; +import org.springframework.test.context.junit4.SpringRunner; + +import io.pivotal.cfenv.spring.boot.CfEnvProcessor; +import io.pivotal.cfenv.spring.boot.ConnectorLibraryDetector; + +/** + * @author Gareth Evans + */ +@RunWith(SpringRunner.class) +@SpringBootTest(classes = BootIntegrationTest.TestConfig.class) +public class BootIntegrationTest { + + public static MockUp mockConnectors(boolean usingConnector) { + return new MockUp() { + @mockit.Mock + public boolean isUsingConnectorLibrary() { + return usingConnector; + } + }; + } + + @Before + public void setUp() { + mockConnectors(true); + } + + @Test + public void test() { + // Should not throw exception + SpringFactoriesLoader.loadFactories(CfEnvProcessor.class, getClass().getClassLoader()); + } + + @Configuration + static class TestConfig { + + } +} diff --git a/java-cfenv-boot-tanzu-genai/src/test/java/io/pivotal/cfenv/boot/genai/GenAICfEnvProcessorTests.java b/java-cfenv-boot-tanzu-genai/src/test/java/io/pivotal/cfenv/boot/genai/GenAICfEnvProcessorTests.java new file mode 100644 index 0000000..19eae7a --- /dev/null +++ b/java-cfenv-boot-tanzu-genai/src/test/java/io/pivotal/cfenv/boot/genai/GenAICfEnvProcessorTests.java @@ -0,0 +1,39 @@ +/* + * Copyright 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 io.pivotal.cfenv.boot.genai; + +import org.junit.Test; + +import io.pivotal.cfenv.test.AbstractCfEnvTests; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Gareth Evans + */ +public class GenAICfEnvProcessorTests extends AbstractCfEnvTests { + + @Test + public void testGenAIBootPropertiesWithEndpointFormat() { + String TEST_GENAI_JSON_FILE = "test-genai-endpoint-chat-model.json"; + + mockVcapServices(getServicesPayload(readTestDataFile(TEST_GENAI_JSON_FILE))); + + assertThat(getEnvironment().getProperty("genai.locator.api-key")).isNotNull(); + assertThat(getEnvironment().getProperty("genai.locator.api-base")).isNotNull(); + assertThat(getEnvironment().getProperty("genai.locator.config-url")).isNotNull(); + } +} diff --git a/java-cfenv-boot-tanzu-genai/src/test/java/io/pivotal/cfenv/boot/genai/GenaiLocatorTest.java b/java-cfenv-boot-tanzu-genai/src/test/java/io/pivotal/cfenv/boot/genai/GenaiLocatorTest.java new file mode 100644 index 0000000..ff9cdd6 --- /dev/null +++ b/java-cfenv-boot-tanzu-genai/src/test/java/io/pivotal/cfenv/boot/genai/GenaiLocatorTest.java @@ -0,0 +1,153 @@ +/* + * Copyright 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 io.pivotal.cfenv.boot.genai; + +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.test.web.client.match.MockRestRequestMatchers; +import org.springframework.test.web.client.response.MockRestResponseCreators; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; + +public class GenaiLocatorTest { + + private MockRestServiceServer server; + + private GenaiLocator locator; + + String configDetails = """ + { + "name": "test", + "description": "test", + "wireFormat": "OPENAI", + "advertisedModels": [ + { + "name": "chat-1", + "description": "", + "capabilities": ["CHAT"] + }, + { + "name": "chat-2", + "description": "", + "capabilities": ["CHAT", "TOOLS"] + }, + { + "name": "embedding-1", + "description": "", + "capabilities": ["EMBEDDING"] + } + ], + "advertisedMcpServers": [ + { + "url":"http://localhost:1234/" + }, + { + "url":"http://localhost:1235/" + }, + { + "url":"http://localhost:1236/" + } + ] + } + """; + + @BeforeEach + public void setup() { + RestClient.Builder restClientBuilder = RestClient.builder(); + server = MockRestServiceServer.bindTo(restClientBuilder).build(); + + server.expect(MockRestRequestMatchers.requestTo("http://ai-server/test/config/v1/endpoint")) + .andRespond(MockRestResponseCreators.withSuccess(configDetails, MediaType.APPLICATION_JSON)); + + locator = + new DefaultGenaiLocator(restClientBuilder, "http://ai-server/test/config/v1/endpoint", "fake-bearer-token", "http://ai-server/test"); + } + + @AfterEach + public void after() { + server.verify(); + } + + @Test + public void canListModels() { + List models = locator.getModelNames(); + assertThat(models).isNotNull(); + assertThat(models).hasSize(3); + assertThat(models).contains("chat-1", "chat-2", "embedding-1"); + } + + @Test + public void canListModelsByChatCapability() { + List chatModels = locator.getModelNamesByCapability("CHAT"); + assertThat(chatModels).isNotNull(); + assertThat(chatModels).hasSize(2); + assertThat(chatModels).contains("chat-1", "chat-2"); + } + + @Test + public void canListModelsByToolsCapability() { + List toolModels = locator.getModelNamesByCapability("TOOLS"); + assertThat(toolModels).isNotNull(); + assertThat(toolModels).hasSize(1); + assertThat(toolModels).contains("chat-2"); + } + + @Test + public void canGetModelByName() { + ChatModel chatModel = locator.getChatModelByName("chat-1"); + assertThat(chatModel).isNotNull(); + } + + @Test + public void canGetFirstAvailableChatModel() { + ChatModel firstAvailableChatModel = locator.getFirstAvailableChatModel(); + assertThat(firstAvailableChatModel).isNotNull(); + } + + @Test + public void canGetFirstAvailableToolModel() { + ChatModel firstAvailableToolModel = locator.getFirstAvailableToolModel(); + assertThat(firstAvailableToolModel).isNotNull(); + } + + @Test + public void canGetEmbeddingModelByName() { + EmbeddingModel embeddingModel = locator.getEmbeddingModelByName("embedding-1"); + assertThat(embeddingModel).isNotNull(); + } + + @Test + public void canGetFirstAvailableEmbeddingModel() { + EmbeddingModel firstAvailableEmbeddingModel = locator.getFirstAvailableEmbeddingModel(); + assertThat(firstAvailableEmbeddingModel).isNotNull(); + } + + @Test + public void canListMcpServers() { + List mcpServers = locator.getMcpServers(); + assertThat(mcpServers).isNotNull(); + assertThat(mcpServers).hasSize(3); + } +} diff --git a/java-cfenv-boot-tanzu-genai/src/test/resources/io/pivotal/cfenv/boot/genai/test-genai-endpoint-chat-model.json b/java-cfenv-boot-tanzu-genai/src/test/resources/io/pivotal/cfenv/boot/genai/test-genai-endpoint-chat-model.json new file mode 100644 index 0000000..3acd317 --- /dev/null +++ b/java-cfenv-boot-tanzu-genai/src/test/resources/io/pivotal/cfenv/boot/genai/test-genai-endpoint-chat-model.json @@ -0,0 +1,20 @@ +{ + "credentials": { + "endpoint": { + "api_base": "https://genai-proxy.tpcf.io/test", + "api_key": "sk-KW5kiNOKDd_1dFxsAjpVa", + "config_url": "https://genai-proxy.tpcf.io/test/config/v1/endpoint" + } + }, + "instance_name": "genai", + "label": "genai", + "name": "genai", + "plan": "meta-llama/Meta-Llama-3-8B", + "provider": null, + "syslog_drain_url": null, + "tags": [ + "genai", + "llm" + ], + "volume_mounts": [] +} \ No newline at end of file diff --git a/java-cfenv-boot-tanzu-genai/src/test/resources/io/pivotal/cfenv/boot/genai/test-genai-endpoint-embedding-model.json b/java-cfenv-boot-tanzu-genai/src/test/resources/io/pivotal/cfenv/boot/genai/test-genai-endpoint-embedding-model.json new file mode 100644 index 0000000..cb88e25 --- /dev/null +++ b/java-cfenv-boot-tanzu-genai/src/test/resources/io/pivotal/cfenv/boot/genai/test-genai-endpoint-embedding-model.json @@ -0,0 +1,20 @@ +{ + "credentials": { + "endpoint": { + "api_base": "https://genai-proxy.tpcf.io/test", + "api_key": "sk-KW5kiNOKDd_1dFxsAjpVa", + "config_url": "https://genai-proxy.tpcf.io/test/config/v1/endpoint" + } + }, + "instance_name": "genai", + "label": "genai", + "name": "genai", + "plan": "mixedbread-ai/mxbai-embed-large-v1", + "provider": null, + "syslog_drain_url": null, + "tags": [ + "genai", + "llm" + ], + "volume_mounts": [] +} \ No newline at end of file diff --git a/java-cfenv-boot/src/main/java/io/pivotal/cfenv/spring/boot/GenAIChatCfEnvProcessor.java b/java-cfenv-boot/src/main/java/io/pivotal/cfenv/spring/boot/GenAIChatCfEnvProcessor.java index 5e722a3..5c3c0b9 100644 --- a/java-cfenv-boot/src/main/java/io/pivotal/cfenv/spring/boot/GenAIChatCfEnvProcessor.java +++ b/java-cfenv-boot/src/main/java/io/pivotal/cfenv/spring/boot/GenAIChatCfEnvProcessor.java @@ -28,6 +28,7 @@ * * @author Stuart Charlton * @author Ed King + * @author Gareth Evans **/ public class GenAIChatCfEnvProcessor implements CfEnvProcessor { @@ -36,7 +37,7 @@ public boolean accept(CfService service) { boolean isGenAIService = service.existsByTagIgnoreCase("genai") || service.existsByLabelStartsWith("genai"); if (isGenAIService) { ArrayList modelCapabilities = (ArrayList) service.getCredentials().getMap().get("model_capabilities"); - return modelCapabilities.contains("chat"); + return (modelCapabilities != null && modelCapabilities.contains("chat")); } return false; diff --git a/java-cfenv-boot/src/main/java/io/pivotal/cfenv/spring/boot/GenAIEmbeddingCfEnvProcessor.java b/java-cfenv-boot/src/main/java/io/pivotal/cfenv/spring/boot/GenAIEmbeddingCfEnvProcessor.java index a6c0d31..2a03f14 100644 --- a/java-cfenv-boot/src/main/java/io/pivotal/cfenv/spring/boot/GenAIEmbeddingCfEnvProcessor.java +++ b/java-cfenv-boot/src/main/java/io/pivotal/cfenv/spring/boot/GenAIEmbeddingCfEnvProcessor.java @@ -28,6 +28,7 @@ * * @author Stuart Charlton * @author Ed King + * @author Gareth Evans **/ public class GenAIEmbeddingCfEnvProcessor implements CfEnvProcessor { @@ -36,7 +37,7 @@ public boolean accept(CfService service) { boolean isGenAIService = service.existsByTagIgnoreCase("genai") || service.existsByLabelStartsWith("genai"); if (isGenAIService) { ArrayList modelCapabilities = (ArrayList) service.getCredentials().getMap().get("model_capabilities"); - return modelCapabilities.contains("embedding"); + return (modelCapabilities != null && modelCapabilities.contains("embedding")); } return false; diff --git a/java-cfenv-boot/src/test/java/io/pivotal/cfenv/spring/boot/GenAIChatCfEnvProcessorTests.java b/java-cfenv-boot/src/test/java/io/pivotal/cfenv/spring/boot/GenAIChatCfEnvProcessorTests.java index 9172983..f963a68 100644 --- a/java-cfenv-boot/src/test/java/io/pivotal/cfenv/spring/boot/GenAIChatCfEnvProcessorTests.java +++ b/java-cfenv-boot/src/test/java/io/pivotal/cfenv/spring/boot/GenAIChatCfEnvProcessorTests.java @@ -24,6 +24,7 @@ /** * @author Stuart Charlton * @author Ed King + * @author Gareth Evans */ public class GenAIChatCfEnvProcessorTests extends AbstractCfEnvTests { @@ -48,4 +49,21 @@ public void testGenAIBootPropertiesWithChatModelCapability() { assertThat(getEnvironment().getProperty("spring.ai.openai.audio.transcription.options.model")).isNull(); assertThat(getEnvironment().getProperty("spring.ai.openai.audio.speech.options.model")).isNull(); } + + @Test + public void testGenAIBootPropertiesWithChatModelCapabilityEndpointFormat() { + String TEST_GENAI_JSON_FILE = "test-genai-endpoint-chat-model.json"; + + mockVcapServices(getServicesPayload(readTestDataFile(TEST_GENAI_JSON_FILE))); + + assertThat(getEnvironment().getProperty("spring.ai.openai.api-key")).isNull(); + assertThat(getEnvironment().getProperty("spring.ai.openai.chat.base-url")).isNull(); + assertThat(getEnvironment().getProperty("spring.ai.openai.chat.api-key")).isNull(); + assertThat(getEnvironment().getProperty("spring.ai.openai.chat.options.model")).isNull(); + + assertThat(getEnvironment().getProperty("spring.ai.openai.embedding.options.model")).isNull(); + assertThat(getEnvironment().getProperty("spring.ai.openai.image.options.model")).isNull(); + assertThat(getEnvironment().getProperty("spring.ai.openai.audio.transcription.options.model")).isNull(); + assertThat(getEnvironment().getProperty("spring.ai.openai.audio.speech.options.model")).isNull(); + } } diff --git a/java-cfenv-boot/src/test/java/io/pivotal/cfenv/spring/boot/GenAIEmbeddingCfEnvProcessorTests.java b/java-cfenv-boot/src/test/java/io/pivotal/cfenv/spring/boot/GenAIEmbeddingCfEnvProcessorTests.java index f3d8268..b93edc9 100644 --- a/java-cfenv-boot/src/test/java/io/pivotal/cfenv/spring/boot/GenAIEmbeddingCfEnvProcessorTests.java +++ b/java-cfenv-boot/src/test/java/io/pivotal/cfenv/spring/boot/GenAIEmbeddingCfEnvProcessorTests.java @@ -24,6 +24,7 @@ /** * @author Stuart Charlton * @author Ed King + * @author Gareth Evans */ public class GenAIEmbeddingCfEnvProcessorTests extends AbstractCfEnvTests { @@ -48,4 +49,22 @@ public void testGenAIBootPropertiesWithEmbeddingModelCapability() { assertThat(getEnvironment().getProperty("spring.ai.openai.audio.transcription.options.model")).isNull(); assertThat(getEnvironment().getProperty("spring.ai.openai.audio.speech.options.model")).isNull(); } + + @Test + public void testGenAIBootPropertiesWithEmbeddingModelCapabilityEndpointFormat() { + String TEST_GENAI_JSON_FILE = "test-genai-endpoint-embedding-model.json"; + + mockVcapServices(getServicesPayload(readTestDataFile(TEST_GENAI_JSON_FILE))); + + assertThat(getEnvironment().getProperty("spring.ai.openai.api-key")).isNull(); + + assertThat(getEnvironment().getProperty("spring.ai.openai.embedding.base-url")).isNull(); + assertThat(getEnvironment().getProperty("spring.ai.openai.embedding.api-key")).isNull(); + assertThat(getEnvironment().getProperty("spring.ai.openai.embedding.options.model")).isNull(); + + assertThat(getEnvironment().getProperty("spring.ai.openai.chat.options.model")).isNull(); + assertThat(getEnvironment().getProperty("spring.ai.openai.image.options.model")).isNull(); + assertThat(getEnvironment().getProperty("spring.ai.openai.audio.transcription.options.model")).isNull(); + assertThat(getEnvironment().getProperty("spring.ai.openai.audio.speech.options.model")).isNull(); + } } diff --git a/java-cfenv-boot/src/test/resources/io/pivotal/cfenv/spring/boot/test-genai-endpoint-chat-model.json b/java-cfenv-boot/src/test/resources/io/pivotal/cfenv/spring/boot/test-genai-endpoint-chat-model.json new file mode 100644 index 0000000..3acd317 --- /dev/null +++ b/java-cfenv-boot/src/test/resources/io/pivotal/cfenv/spring/boot/test-genai-endpoint-chat-model.json @@ -0,0 +1,20 @@ +{ + "credentials": { + "endpoint": { + "api_base": "https://genai-proxy.tpcf.io/test", + "api_key": "sk-KW5kiNOKDd_1dFxsAjpVa", + "config_url": "https://genai-proxy.tpcf.io/test/config/v1/endpoint" + } + }, + "instance_name": "genai", + "label": "genai", + "name": "genai", + "plan": "meta-llama/Meta-Llama-3-8B", + "provider": null, + "syslog_drain_url": null, + "tags": [ + "genai", + "llm" + ], + "volume_mounts": [] +} \ No newline at end of file diff --git a/java-cfenv-boot/src/test/resources/io/pivotal/cfenv/spring/boot/test-genai-endpoint-embedding-model.json b/java-cfenv-boot/src/test/resources/io/pivotal/cfenv/spring/boot/test-genai-endpoint-embedding-model.json new file mode 100644 index 0000000..cb88e25 --- /dev/null +++ b/java-cfenv-boot/src/test/resources/io/pivotal/cfenv/spring/boot/test-genai-endpoint-embedding-model.json @@ -0,0 +1,20 @@ +{ + "credentials": { + "endpoint": { + "api_base": "https://genai-proxy.tpcf.io/test", + "api_key": "sk-KW5kiNOKDd_1dFxsAjpVa", + "config_url": "https://genai-proxy.tpcf.io/test/config/v1/endpoint" + } + }, + "instance_name": "genai", + "label": "genai", + "name": "genai", + "plan": "mixedbread-ai/mxbai-embed-large-v1", + "provider": null, + "syslog_drain_url": null, + "tags": [ + "genai", + "llm" + ], + "volume_mounts": [] +} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 281e4c5..7a82994 100644 --- a/settings.gradle +++ b/settings.gradle @@ -33,6 +33,7 @@ include "java-cfenv-boot" include "java-cfenv-tests-boot" include "java-cfenv-boot-pivotal-scs" include "java-cfenv-boot-pivotal-sso" +include "java-cfenv-boot-tanzu-genai" include "java-cfenv-all" //include "java-cfenv-docs" include "java-cfenv-jdbc"