From 1c6b973e25e663b01dedb320fd8f76934e830df0 Mon Sep 17 00:00:00 2001 From: Gareth Evans Date: Wed, 11 Jun 2025 14:55:05 +0100 Subject: [PATCH 1/4] chore: support the new genai endpoint format --- .../spring/boot/GenAIChatCfEnvProcessor.java | 3 ++- .../boot/GenAIEmbeddingCfEnvProcessor.java | 3 ++- .../boot/GenAIChatCfEnvProcessorTests.java | 18 +++++++++++++++++ .../GenAIEmbeddingCfEnvProcessorTests.java | 19 ++++++++++++++++++ .../boot/test-genai-endpoint-chat-model.json | 20 +++++++++++++++++++ .../test-genai-endpoint-embedding-model.json | 20 +++++++++++++++++++ 6 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 java-cfenv-boot/src/test/resources/io/pivotal/cfenv/spring/boot/test-genai-endpoint-chat-model.json create mode 100644 java-cfenv-boot/src/test/resources/io/pivotal/cfenv/spring/boot/test-genai-endpoint-embedding-model.json 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 From fc3725f5e2477fef930daa53853e9cc00e6b5447 Mon Sep 17 00:00:00 2001 From: Gareth Evans Date: Tue, 1 Jul 2025 10:48:08 +0100 Subject: [PATCH 2/4] feat: add a new genai locator module --- build.gradle | 1 + java-cfenv-boot-tanzu-genai/README.md | 21 ++ java-cfenv-boot-tanzu-genai/build.gradle | 25 ++ .../cfenv/boot/genai/CfGenaiProcessor.java | 34 ++ .../cfenv/boot/genai/DefaultGenAILocator.java | 296 ++++++++++++++++++ .../cfenv/boot/genai/GenAILocator.java | 46 +++ .../genai/GenAILocatorAutoConfiguration.java | 18 ++ .../main/resources/META-INF/spring.factories | 2 + ...ot.autoconfigure.AutoConfiguration.imports | 2 + .../cfenv/boot/genai/BootIntegrationTest.java | 62 ++++ .../boot/genai/GenAICfEnvProcessorTests.java | 38 +++ .../genai/test-genai-endpoint-chat-model.json | 20 ++ .../test-genai-endpoint-embedding-model.json | 20 ++ settings.gradle | 1 + 14 files changed, 586 insertions(+) create mode 100644 java-cfenv-boot-tanzu-genai/README.md create mode 100644 java-cfenv-boot-tanzu-genai/build.gradle create mode 100644 java-cfenv-boot-tanzu-genai/src/main/java/io/pivotal/cfenv/boot/genai/CfGenaiProcessor.java create mode 100644 java-cfenv-boot-tanzu-genai/src/main/java/io/pivotal/cfenv/boot/genai/DefaultGenAILocator.java create mode 100644 java-cfenv-boot-tanzu-genai/src/main/java/io/pivotal/cfenv/boot/genai/GenAILocator.java create mode 100644 java-cfenv-boot-tanzu-genai/src/main/java/io/pivotal/cfenv/boot/genai/GenAILocatorAutoConfiguration.java create mode 100644 java-cfenv-boot-tanzu-genai/src/main/resources/META-INF/spring.factories create mode 100644 java-cfenv-boot-tanzu-genai/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 java-cfenv-boot-tanzu-genai/src/test/java/io/pivotal/cfenv/boot/genai/BootIntegrationTest.java create mode 100644 java-cfenv-boot-tanzu-genai/src/test/java/io/pivotal/cfenv/boot/genai/GenAICfEnvProcessorTests.java create mode 100644 java-cfenv-boot-tanzu-genai/src/test/resources/io/pivotal/cfenv/boot/genai/test-genai-endpoint-chat-model.json create mode 100644 java-cfenv-boot-tanzu-genai/src/test/resources/io/pivotal/cfenv/boot/genai/test-genai-endpoint-embedding-model.json 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/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..4526e73 --- /dev/null +++ b/java-cfenv-boot-tanzu-genai/src/main/java/io/pivotal/cfenv/boot/genai/CfGenaiProcessor.java @@ -0,0 +1,34 @@ +package io.pivotal.cfenv.boot.genai; + +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; + +import java.util.Map; + +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..b730218 --- /dev/null +++ b/java-cfenv-boot-tanzu-genai/src/main/java/io/pivotal/cfenv/boot/genai/DefaultGenAILocator.java @@ -0,0 +1,296 @@ +package io.pivotal.cfenv.boot.genai; + +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; + +import java.util.AbstractMap; +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; + +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; + + public DefaultGenAILocator(String configUrl, String apiKey, String apiBase) { + 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 = RestClient.builder().build(); + 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..5b8c161 --- /dev/null +++ b/java-cfenv-boot-tanzu-genai/src/main/java/io/pivotal/cfenv/boot/genai/GenAILocator.java @@ -0,0 +1,46 @@ +package io.pivotal.cfenv.boot.genai; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.embedding.EmbeddingModel; + +import java.util.List; +import java.util.Map; + +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..8dd65e8 --- /dev/null +++ b/java-cfenv-boot-tanzu-genai/src/main/java/io/pivotal/cfenv/boot/genai/GenAILocatorAutoConfiguration.java @@ -0,0 +1,18 @@ +package io.pivotal.cfenv.boot.genai; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; + +@AutoConfiguration +public class GenAILocatorAutoConfiguration { + + @Bean + public GenAILocator genAILocator( + @Value("genai.locator.config-url") String configUrl, + @Value("genai.locator.api-key") String apiKey, + @Value("genai.locator.api-base") String apiBase + ) { + return new DefaultGenAILocator(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..5c5b6cd --- /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..12975c1 --- /dev/null +++ b/java-cfenv-boot-tanzu-genai/src/test/java/io/pivotal/cfenv/boot/genai/BootIntegrationTest.java @@ -0,0 +1,62 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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..1a37c06 --- /dev/null +++ b/java-cfenv-boot-tanzu-genai/src/test/java/io/pivotal/cfenv/boot/genai/GenAICfEnvProcessorTests.java @@ -0,0 +1,38 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.pivotal.cfenv.boot.genai; + +import io.pivotal.cfenv.test.AbstractCfEnvTests; +import org.junit.Test; + +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/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/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" From 0b7d4799968b89ec4f2460bef3d3d6d5646779ba Mon Sep 17 00:00:00 2001 From: Gareth Evans Date: Tue, 1 Jul 2025 12:05:53 +0100 Subject: [PATCH 3/4] chore: adding tests to mock out calls to config endpoin --- .../cfenv/boot/genai/CfGenaiProcessor.java | 25 ++- ...ILocator.java => DefaultGenaiLocator.java} | 62 ++++--- .../genai/GenAILocatorAutoConfiguration.java | 18 --- .../{GenAILocator.java => GenaiLocator.java} | 29 +++- .../genai/GenaiLocatorAutoConfiguration.java | 42 +++++ ...ot.autoconfigure.AutoConfiguration.imports | 2 +- .../cfenv/boot/genai/BootIntegrationTest.java | 2 +- .../boot/genai/GenAICfEnvProcessorTests.java | 5 +- .../cfenv/boot/genai/GenaiLocatorTest.java | 153 ++++++++++++++++++ 9 files changed, 290 insertions(+), 48 deletions(-) rename java-cfenv-boot-tanzu-genai/src/main/java/io/pivotal/cfenv/boot/genai/{DefaultGenAILocator.java => DefaultGenaiLocator.java} (85%) delete mode 100644 java-cfenv-boot-tanzu-genai/src/main/java/io/pivotal/cfenv/boot/genai/GenAILocatorAutoConfiguration.java rename java-cfenv-boot-tanzu-genai/src/main/java/io/pivotal/cfenv/boot/genai/{GenAILocator.java => GenaiLocator.java} (63%) create mode 100644 java-cfenv-boot-tanzu-genai/src/main/java/io/pivotal/cfenv/boot/genai/GenaiLocatorAutoConfiguration.java create mode 100644 java-cfenv-boot-tanzu-genai/src/test/java/io/pivotal/cfenv/boot/genai/GenaiLocatorTest.java 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 index 4526e73..090707c 100644 --- 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 @@ -1,12 +1,33 @@ +/* + * 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; -import java.util.Map; - +/** + * 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 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 similarity index 85% rename from java-cfenv-boot-tanzu-genai/src/main/java/io/pivotal/cfenv/boot/genai/DefaultGenAILocator.java rename to java-cfenv-boot-tanzu-genai/src/main/java/io/pivotal/cfenv/boot/genai/DefaultGenaiLocator.java index b730218..cc05404 100644 --- 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 @@ -1,9 +1,29 @@ +/* + * 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; @@ -14,20 +34,22 @@ import org.springframework.ai.openai.api.OpenAiApi; import org.springframework.web.client.RestClient; -import java.util.AbstractMap; -import java.util.List; -import java.util.Map; -import java.util.function.Predicate; - -public class DefaultGenAILocator implements GenAILocator { +/** + * 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 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(String configUrl, String apiKey, String apiBase) { + public DefaultGenaiLocator(RestClient.Builder builder, String configUrl, String apiKey, String apiBase) { + this.builder = builder; this.configUrl = configUrl; this.apiKey = apiKey; this.apiBase = apiBase; @@ -67,7 +89,7 @@ public ChatModel getChatModelByName(String name) { return models.stream() .filter(filterModelConnectivityOnCapability("CHAT")) .filter(c -> c.name().equals(name)) - .map(DefaultGenAILocator::createChatModel) + .map(DefaultGenaiLocator::createChatModel) .findFirst() .orElseThrow( () -> new RuntimeException("Unable to find chat model with name '" + name + "'")); @@ -80,7 +102,7 @@ public List getChatModelsByLabels(Map labels) { return models.stream() .filter(filterModelConnectivityOnCapability("CHAT")) .filter(filterModelConnectivityOnLabels(labels)) - .map(DefaultGenAILocator::createChatModel) + .map(DefaultGenaiLocator::createChatModel) .toList(); } @@ -90,7 +112,7 @@ public ChatModel getFirstAvailableChatModel() { return models.stream() .filter(filterModelConnectivityOnCapability("CHAT")) - .map(DefaultGenAILocator::createChatModel) + .map(DefaultGenaiLocator::createChatModel) .findFirst() .orElseThrow(() -> new RuntimeException("Unable to find first chat model")); } @@ -102,7 +124,7 @@ public ChatModel getFirstAvailableChatModelByLabels(Map labels) return models.stream() .filter(filterModelConnectivityOnCapability("CHAT")) .filter(filterModelConnectivityOnLabels(labels)) - .map(DefaultGenAILocator::createChatModel) + .map(DefaultGenaiLocator::createChatModel) .findFirst() .orElseThrow(() -> new RuntimeException("Unable to find first chat model")); } @@ -114,7 +136,7 @@ public List getToolModelsByLabels(Map labels) { return models.stream() .filter(filterModelConnectivityOnCapability("TOOLS")) .filter(filterModelConnectivityOnLabels(labels)) - .map(DefaultGenAILocator::createChatModel) + .map(DefaultGenaiLocator::createChatModel) .toList(); } @@ -124,7 +146,7 @@ public ChatModel getFirstAvailableToolModel() { return models.stream() .filter(filterModelConnectivityOnCapability("TOOLS")) - .map(DefaultGenAILocator::createChatModel) + .map(DefaultGenaiLocator::createChatModel) .findFirst() .orElseThrow(() -> new RuntimeException("Unable to find first tool model")); } @@ -136,7 +158,7 @@ public ChatModel getFirstAvailableToolModelByLabels(Map labels) return models.stream() .filter(filterModelConnectivityOnCapability("TOOLS")) .filter(filterModelConnectivityOnLabels(labels)) - .map(DefaultGenAILocator::createChatModel) + .map(DefaultGenaiLocator::createChatModel) .findFirst() .orElseThrow(() -> new RuntimeException("Unable to find first tool model")); } @@ -156,7 +178,7 @@ public EmbeddingModel getEmbeddingModelByName(String name) { return models.stream() .filter(filterModelConnectivityOnCapability("EMBEDDING")) .filter(c -> c.name().equals(name)) - .map(DefaultGenAILocator::createEmbeddingModel) + .map(DefaultGenaiLocator::createEmbeddingModel) .findFirst() .orElseThrow( () -> new RuntimeException("Unable to find embedding model with name '" + name + "'")); @@ -169,7 +191,7 @@ public List getEmbeddingModelsByLabels(Map label return models.stream() .filter(filterModelConnectivityOnCapability("EMBEDDING")) .filter(filterModelConnectivityOnLabels(labels)) - .map(DefaultGenAILocator::createEmbeddingModel) + .map(DefaultGenaiLocator::createEmbeddingModel) .toList(); } @@ -179,7 +201,7 @@ public EmbeddingModel getFirstAvailableEmbeddingModel() { return models.stream() .filter(filterModelConnectivityOnCapability("EMBEDDING")) - .map(DefaultGenAILocator::createEmbeddingModel) + .map(DefaultGenaiLocator::createEmbeddingModel) .findFirst() .orElseThrow(() -> new RuntimeException("Unable to find first embedding model")); } @@ -191,7 +213,7 @@ public EmbeddingModel getFirstAvailableEmbeddingModelByLabels(Map new RuntimeException("Unable to find first embedding model")); } @@ -231,7 +253,7 @@ private List getAllMcpConnectivityDetails() { } private ConfigEndpoint getEndpointConfig() { - RestClient client = RestClient.builder().build(); + RestClient client = builder.build(); return client .get() .uri(configUrl) 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 deleted file mode 100644 index 8dd65e8..0000000 --- a/java-cfenv-boot-tanzu-genai/src/main/java/io/pivotal/cfenv/boot/genai/GenAILocatorAutoConfiguration.java +++ /dev/null @@ -1,18 +0,0 @@ -package io.pivotal.cfenv.boot.genai; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.AutoConfiguration; -import org.springframework.context.annotation.Bean; - -@AutoConfiguration -public class GenAILocatorAutoConfiguration { - - @Bean - public GenAILocator genAILocator( - @Value("genai.locator.config-url") String configUrl, - @Value("genai.locator.api-key") String apiKey, - @Value("genai.locator.api-base") String apiBase - ) { - return new DefaultGenAILocator(configUrl, apiKey, apiBase); - } -} 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 similarity index 63% rename from java-cfenv-boot-tanzu-genai/src/main/java/io/pivotal/cfenv/boot/genai/GenAILocator.java rename to java-cfenv-boot-tanzu-genai/src/main/java/io/pivotal/cfenv/boot/genai/GenaiLocator.java index 5b8c161..aa481a3 100644 --- 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 @@ -1,13 +1,34 @@ +/* + * 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; -import java.util.List; -import java.util.Map; - -public interface GenAILocator { +/** + * Locates available models and mcp servers from ai-servers config endpoint + * + * @author Gareth Evans + **/ +public interface GenaiLocator { List getModelNames(); 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..958f09c --- /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/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/java-cfenv-boot-tanzu-genai/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 5c5b6cd..32cb06c 100644 --- 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 @@ -1,2 +1,2 @@ -io.pivotal.cfenv.boot.genai.GenAILocatorAutoConfiguration +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 index 12975c1..e735302 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2019 the original author or authors. + * 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. 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 index 1a37c06..19eae7a 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2024 the original author or authors. + * 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. @@ -15,9 +15,10 @@ */ package io.pivotal.cfenv.boot.genai; -import io.pivotal.cfenv.test.AbstractCfEnvTests; import org.junit.Test; +import io.pivotal.cfenv.test.AbstractCfEnvTests; + import static org.assertj.core.api.Assertions.assertThat; /** 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); + } +} From 0e1562a404b6ce2edeedce167183af8c63959711 Mon Sep 17 00:00:00 2001 From: Gareth Evans Date: Tue, 1 Jul 2025 15:15:54 +0100 Subject: [PATCH 4/4] chore: correct the parameter passing of properties --- gradle.properties | 2 +- .../io/pivotal/cfenv/boot/genai/DefaultGenaiLocator.java | 1 + .../cfenv/boot/genai/GenaiLocatorAutoConfiguration.java | 6 +++--- 3 files changed, 5 insertions(+), 4 deletions(-) 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/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 index cc05404..5f5cf92 100644 --- 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 @@ -254,6 +254,7 @@ private List getAllMcpConnectivityDetails() { private ConfigEndpoint getEndpointConfig() { RestClient client = builder.build(); + LOGGER.info("Retrieving config from url {}", configUrl); return client .get() .uri(configUrl) 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 index 958f09c..7349c8e 100644 --- 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 @@ -33,9 +33,9 @@ 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 + @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); }