From cc53ca31fb180934d99f7bd50dfc33f663999ea4 Mon Sep 17 00:00:00 2001 From: Georgios Andrianakis Date: Mon, 15 Jan 2024 17:35:58 +0200 Subject: [PATCH] Introduce first version of OpenShift AI extension --- openshift-ai/deployment/pom.xml | 65 ++++++++++ .../ai/deployment/ChatModelBuildConfig.java | 16 +++ .../Langchain4jOpenshiftAiBuildConfig.java | 16 +++ .../ai/deployment/OpenshiftAiProcessor.java | 56 +++++++++ openshift-ai/pom.xml | 21 ++++ openshift-ai/runtime/pom.xml | 84 +++++++++++++ .../openshiftai/OpenshiftAiChatModel.java | 106 ++++++++++++++++ .../openshiftai/OpenshiftAiRestApi.java | 118 ++++++++++++++++++ .../openshiftai/TextGenerationRequest.java | 5 + .../openshiftai/TextGenerationResponse.java | 4 + .../runtime/OpenshiftAiRecorder.java | 31 +++++ .../runtime/config/ChatModelConfig.java | 12 ++ .../config/Langchain4jOpenshiftAiConfig.java | 44 +++++++ .../src/main/resources/META-INF/beans.xml | 0 .../resources/META-INF/quarkus-extension.yaml | 11 ++ pom.xml | 1 + 16 files changed, 590 insertions(+) create mode 100644 openshift-ai/deployment/pom.xml create mode 100644 openshift-ai/deployment/src/main/java/io/quarkiverse/langchain4j/openshift/ai/deployment/ChatModelBuildConfig.java create mode 100644 openshift-ai/deployment/src/main/java/io/quarkiverse/langchain4j/openshift/ai/deployment/Langchain4jOpenshiftAiBuildConfig.java create mode 100644 openshift-ai/deployment/src/main/java/io/quarkiverse/langchain4j/openshift/ai/deployment/OpenshiftAiProcessor.java create mode 100644 openshift-ai/pom.xml create mode 100644 openshift-ai/runtime/pom.xml create mode 100644 openshift-ai/runtime/src/main/java/io/quarkiverse/langchain4j/openshiftai/OpenshiftAiChatModel.java create mode 100644 openshift-ai/runtime/src/main/java/io/quarkiverse/langchain4j/openshiftai/OpenshiftAiRestApi.java create mode 100644 openshift-ai/runtime/src/main/java/io/quarkiverse/langchain4j/openshiftai/TextGenerationRequest.java create mode 100644 openshift-ai/runtime/src/main/java/io/quarkiverse/langchain4j/openshiftai/TextGenerationResponse.java create mode 100644 openshift-ai/runtime/src/main/java/io/quarkiverse/langchain4j/openshiftai/runtime/OpenshiftAiRecorder.java create mode 100644 openshift-ai/runtime/src/main/java/io/quarkiverse/langchain4j/openshiftai/runtime/config/ChatModelConfig.java create mode 100644 openshift-ai/runtime/src/main/java/io/quarkiverse/langchain4j/openshiftai/runtime/config/Langchain4jOpenshiftAiConfig.java create mode 100644 openshift-ai/runtime/src/main/resources/META-INF/beans.xml create mode 100644 openshift-ai/runtime/src/main/resources/META-INF/quarkus-extension.yaml diff --git a/openshift-ai/deployment/pom.xml b/openshift-ai/deployment/pom.xml new file mode 100644 index 000000000..b4088cdd3 --- /dev/null +++ b/openshift-ai/deployment/pom.xml @@ -0,0 +1,65 @@ + + + 4.0.0 + + io.quarkiverse.langchain4j + quarkus-langchain4j-openshift-ai-parent + 999-SNAPSHOT + + + + quarkus-langchain4j-openshift-ai-deployment + Quarkus Langchain4j - OpenShift AI - Deployment + + + io.quarkiverse.langchain4j + quarkus-langchain4j-openshift-ai + ${project.version} + + + io.quarkus + quarkus-rest-client-reactive-jackson-deployment + + + io.quarkiverse.langchain4j + quarkus-langchain4j-core-deployment + ${project.version} + + + io.quarkus + quarkus-junit5-internal + test + + + org.assertj + assertj-core + ${assertj.version} + test + + + org.wiremock + wiremock-standalone + ${wiremock.version} + test + + + + + + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${quarkus.version} + + + + + + + + diff --git a/openshift-ai/deployment/src/main/java/io/quarkiverse/langchain4j/openshift/ai/deployment/ChatModelBuildConfig.java b/openshift-ai/deployment/src/main/java/io/quarkiverse/langchain4j/openshift/ai/deployment/ChatModelBuildConfig.java new file mode 100644 index 000000000..c77be414d --- /dev/null +++ b/openshift-ai/deployment/src/main/java/io/quarkiverse/langchain4j/openshift/ai/deployment/ChatModelBuildConfig.java @@ -0,0 +1,16 @@ +package io.quarkiverse.langchain4j.openshift.ai.deployment; + +import java.util.Optional; + +import io.quarkus.runtime.annotations.ConfigDocDefault; +import io.quarkus.runtime.annotations.ConfigGroup; + +@ConfigGroup +public interface ChatModelBuildConfig { + + /** + * Whether the model should be enabled + */ + @ConfigDocDefault("true") + Optional enabled(); +} diff --git a/openshift-ai/deployment/src/main/java/io/quarkiverse/langchain4j/openshift/ai/deployment/Langchain4jOpenshiftAiBuildConfig.java b/openshift-ai/deployment/src/main/java/io/quarkiverse/langchain4j/openshift/ai/deployment/Langchain4jOpenshiftAiBuildConfig.java new file mode 100644 index 000000000..2ef5bde41 --- /dev/null +++ b/openshift-ai/deployment/src/main/java/io/quarkiverse/langchain4j/openshift/ai/deployment/Langchain4jOpenshiftAiBuildConfig.java @@ -0,0 +1,16 @@ +package io.quarkiverse.langchain4j.openshift.ai.deployment; + +import static io.quarkus.runtime.annotations.ConfigPhase.BUILD_TIME; + +import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; + +@ConfigRoot(phase = BUILD_TIME) +@ConfigMapping(prefix = "quarkus.langchain4j.openshift-ai") +public interface Langchain4jOpenshiftAiBuildConfig { + + /** + * Chat model related settings + */ + ChatModelBuildConfig chatModel(); +} diff --git a/openshift-ai/deployment/src/main/java/io/quarkiverse/langchain4j/openshift/ai/deployment/OpenshiftAiProcessor.java b/openshift-ai/deployment/src/main/java/io/quarkiverse/langchain4j/openshift/ai/deployment/OpenshiftAiProcessor.java new file mode 100644 index 000000000..56d265290 --- /dev/null +++ b/openshift-ai/deployment/src/main/java/io/quarkiverse/langchain4j/openshift/ai/deployment/OpenshiftAiProcessor.java @@ -0,0 +1,56 @@ +package io.quarkiverse.langchain4j.openshift.ai.deployment; + +import static io.quarkiverse.langchain4j.deployment.Langchain4jDotNames.CHAT_MODEL; + +import java.util.Optional; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkiverse.langchain4j.deployment.items.ChatModelProviderCandidateBuildItem; +import io.quarkiverse.langchain4j.deployment.items.SelectedChatModelProviderBuildItem; +import io.quarkiverse.langchain4j.openshiftai.runtime.OpenshiftAiRecorder; +import io.quarkiverse.langchain4j.openshiftai.runtime.config.Langchain4jOpenshiftAiConfig; +import io.quarkus.arc.deployment.SyntheticBeanBuildItem; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.ExecutionTime; +import io.quarkus.deployment.annotations.Record; +import io.quarkus.deployment.builditem.FeatureBuildItem; + +public class OpenshiftAiProcessor { + + private static final String FEATURE = "langchain4j-openshift-ai"; + + private static final String PROVIDER = "openshift-ai"; + + @BuildStep + FeatureBuildItem feature() { + return new FeatureBuildItem(FEATURE); + } + + @BuildStep + public void providerCandidates(BuildProducer chatProducer, + Langchain4jOpenshiftAiBuildConfig config) { + if (config.chatModel().enabled().isEmpty() || config.chatModel().enabled().get()) { + chatProducer.produce(new ChatModelProviderCandidateBuildItem(PROVIDER)); + } + } + + @SuppressWarnings("OptionalUsedAsFieldOrParameterType") + @BuildStep + @Record(ExecutionTime.RUNTIME_INIT) + void generateBeans(OpenshiftAiRecorder recorder, + Optional selectedChatItem, + Langchain4jOpenshiftAiConfig config, + BuildProducer beanProducer) { + if (selectedChatItem.isPresent() && PROVIDER.equals(selectedChatItem.get().getProvider())) { + beanProducer.produce(SyntheticBeanBuildItem + .configure(CHAT_MODEL) + .setRuntimeInit() + .defaultBean() + .scope(ApplicationScoped.class) + .supplier(recorder.chatModel(config)) + .done()); + } + } +} diff --git a/openshift-ai/pom.xml b/openshift-ai/pom.xml new file mode 100644 index 000000000..21f17dec9 --- /dev/null +++ b/openshift-ai/pom.xml @@ -0,0 +1,21 @@ + + + 4.0.0 + + io.quarkiverse.langchain4j + quarkus-langchain4j-parent + 999-SNAPSHOT + + quarkus-langchain4j-openshift-ai-parent + Quarkus Langchain4j - OpenShift AI - Parent + pom + + + deployment + runtime + + + + diff --git a/openshift-ai/runtime/pom.xml b/openshift-ai/runtime/pom.xml new file mode 100644 index 000000000..361e948f7 --- /dev/null +++ b/openshift-ai/runtime/pom.xml @@ -0,0 +1,84 @@ + + + 4.0.0 + + io.quarkiverse.langchain4j + quarkus-langchain4j-openshift-ai-parent + 999-SNAPSHOT + + + quarkus-langchain4j-openshift-ai + Quarkus Langchain4j - OpenShift AI - Runtime + + + + io.quarkus + quarkus-arc + + + io.quarkus + quarkus-rest-client-reactive-jackson + + + io.quarkiverse.langchain4j + quarkus-langchain4j-core + ${project.version} + + + + + + io.quarkus + quarkus-extension-maven-plugin + ${quarkus.version} + + + compile + + extension-descriptor + + + ${project.groupId}:${project.artifactId}-deployment:${project.version} + + + + + + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${quarkus.version} + + + + + + maven-jar-plugin + + + generate-codestart-jar + generate-resources + + jar + + + ${project.basedir}/src/main + + codestarts/** + + codestarts + true + + + + + + + + + diff --git a/openshift-ai/runtime/src/main/java/io/quarkiverse/langchain4j/openshiftai/OpenshiftAiChatModel.java b/openshift-ai/runtime/src/main/java/io/quarkiverse/langchain4j/openshiftai/OpenshiftAiChatModel.java new file mode 100644 index 000000000..59750a429 --- /dev/null +++ b/openshift-ai/runtime/src/main/java/io/quarkiverse/langchain4j/openshiftai/OpenshiftAiChatModel.java @@ -0,0 +1,106 @@ +package io.quarkiverse.langchain4j.openshiftai; + +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.jboss.resteasy.reactive.client.api.LoggingScope; + +import dev.langchain4j.agent.tool.ToolSpecification; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.model.chat.ChatLanguageModel; +import dev.langchain4j.model.output.Response; +import io.quarkus.rest.client.reactive.QuarkusRestClientBuilder; + +public class OpenshiftAiChatModel implements ChatLanguageModel { + public static final String TLS_TRUST_ALL = "quarkus.tls.trust-all"; + private final String modelId; + private final OpenshiftAiRestApi client; + + public OpenshiftAiChatModel(Builder config) { + QuarkusRestClientBuilder builder = QuarkusRestClientBuilder.newBuilder() + .baseUri(config.url) + .connectTimeout(config.timeout.toSeconds(), TimeUnit.SECONDS) + .readTimeout(config.timeout.toSeconds(), TimeUnit.SECONDS); + + if (config.logRequests || config.logResponses) { + builder.loggingScope(LoggingScope.REQUEST_RESPONSE); + builder.clientLogger(new OpenshiftAiRestApi.OpenshiftAiClientLogger(config.logRequests, + config.logResponses)); + } + + this.client = builder.build(OpenshiftAiRestApi.class); + this.modelId = config.modelId; + } + + public static Builder builder() { + return new Builder(); + } + + @Override + public Response generate(List messages) { + + TextGenerationRequest request = new TextGenerationRequest(modelId, messages.get(0).text()); + + TextGenerationResponse textGenerationResponse = client.chat(request); + + return Response.from(AiMessage.from(textGenerationResponse.generatedText())); + } + + @Override + public Response generate(List messages, List toolSpecifications) { + throw new IllegalArgumentException("Tools are currently not supported for OpenShift AI models"); + } + + @Override + public Response generate(List messages, ToolSpecification toolSpecification) { + throw new IllegalArgumentException("Tools are currently not supported for OpenShift AI models"); + } + + public static final class Builder { + + private String modelId; + private Duration timeout = Duration.ofSeconds(15); + + private URI url; + public boolean logResponses; + public boolean logRequests; + + public Builder modelId(String modelId) { + this.modelId = modelId; + return this; + } + + public Builder url(URL url) { + try { + this.url = url.toURI(); + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + return this; + } + + public Builder timeout(Duration timeout) { + this.timeout = timeout; + return this; + } + + public OpenshiftAiChatModel build() { + return new OpenshiftAiChatModel(this); + } + + public Builder logRequests(boolean logRequests) { + this.logRequests = logRequests; + return this; + } + + public Builder logResponses(boolean logResponses) { + this.logResponses = logResponses; + return this; + } + } +} diff --git a/openshift-ai/runtime/src/main/java/io/quarkiverse/langchain4j/openshiftai/OpenshiftAiRestApi.java b/openshift-ai/runtime/src/main/java/io/quarkiverse/langchain4j/openshiftai/OpenshiftAiRestApi.java new file mode 100644 index 000000000..9dcaeeec8 --- /dev/null +++ b/openshift-ai/runtime/src/main/java/io/quarkiverse/langchain4j/openshiftai/OpenshiftAiRestApi.java @@ -0,0 +1,118 @@ +package io.quarkiverse.langchain4j.openshiftai; + +import static java.util.stream.Collectors.joining; +import static java.util.stream.StreamSupport.stream; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +import org.jboss.logging.Logger; +import org.jboss.resteasy.reactive.client.api.ClientLogger; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.quarkiverse.langchain4j.QuarkusJsonCodecFactory; +import io.quarkus.rest.client.reactive.jackson.ClientObjectMapper; +import io.vertx.core.Handler; +import io.vertx.core.MultiMap; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpClientRequest; +import io.vertx.core.http.HttpClientResponse; + +/** + * This Microprofile REST client is used as the building block of all the API calls to OpenShift AI. + * The implementation is provided by the Reactive REST Client in Quarkus. + */ + +@Path("/v1/task") +@Consumes(MediaType.APPLICATION_JSON) +@Produces(MediaType.APPLICATION_JSON) +public interface OpenshiftAiRestApi { + + @POST + @Path("text-generation") + TextGenerationResponse chat(TextGenerationRequest request); + + @ClientObjectMapper + static ObjectMapper objectMapper(ObjectMapper defaultObjectMapper) { + return QuarkusJsonCodecFactory.SnakeCaseObjectMapperHolder.MAPPER; + } + + /** + * Introduce a custom logger as the stock one logs at the DEBUG level by default... + */ + class OpenshiftAiClientLogger implements ClientLogger { + private static final Logger log = Logger.getLogger(OpenshiftAiClientLogger.class); + + private final boolean logRequests; + private final boolean logResponses; + + public OpenshiftAiClientLogger(boolean logRequests, boolean logResponses) { + this.logRequests = logRequests; + this.logResponses = logResponses; + } + + @Override + public void setBodySize(int bodySize) { + // ignore + } + + @Override + public void logRequest(HttpClientRequest request, Buffer body, boolean omitBody) { + if (!logRequests || !log.isInfoEnabled()) { + return; + } + try { + log.infof("Request:\n- method: %s\n- url: %s\n- headers: %s\n- body: %s", + request.getMethod(), + request.absoluteURI(), + inOneLine(request.headers()), + bodyToString(body)); + } catch (Exception e) { + log.warn("Failed to log request", e); + } + } + + @Override + public void logResponse(HttpClientResponse response, boolean redirect) { + if (!logResponses || !log.isInfoEnabled()) { + return; + } + response.bodyHandler(new Handler<>() { + @Override + public void handle(Buffer body) { + try { + log.infof( + "Response:\n- status code: %s\n- headers: %s\n- body: %s", + response.statusCode(), + inOneLine(response.headers()), + bodyToString(body)); + } catch (Exception e) { + log.warn("Failed to log response", e); + } + } + }); + } + + private String bodyToString(Buffer body) { + if (body == null) { + return ""; + } + return body.toString(); + } + + private String inOneLine(MultiMap headers) { + + return stream(headers.spliterator(), false) + .map(header -> { + String headerKey = header.getKey(); + String headerValue = header.getValue(); + return String.format("[%s: %s]", headerKey, headerValue); + }) + .collect(joining(", ")); + } + } +} diff --git a/openshift-ai/runtime/src/main/java/io/quarkiverse/langchain4j/openshiftai/TextGenerationRequest.java b/openshift-ai/runtime/src/main/java/io/quarkiverse/langchain4j/openshiftai/TextGenerationRequest.java new file mode 100644 index 000000000..25bc6b0a2 --- /dev/null +++ b/openshift-ai/runtime/src/main/java/io/quarkiverse/langchain4j/openshiftai/TextGenerationRequest.java @@ -0,0 +1,5 @@ +package io.quarkiverse.langchain4j.openshiftai; + +public record TextGenerationRequest(String modelId, String inputs) { + +} diff --git a/openshift-ai/runtime/src/main/java/io/quarkiverse/langchain4j/openshiftai/TextGenerationResponse.java b/openshift-ai/runtime/src/main/java/io/quarkiverse/langchain4j/openshiftai/TextGenerationResponse.java new file mode 100644 index 000000000..8074fe6cd --- /dev/null +++ b/openshift-ai/runtime/src/main/java/io/quarkiverse/langchain4j/openshiftai/TextGenerationResponse.java @@ -0,0 +1,4 @@ +package io.quarkiverse.langchain4j.openshiftai; + +public record TextGenerationResponse(String generatedText) { +} diff --git a/openshift-ai/runtime/src/main/java/io/quarkiverse/langchain4j/openshiftai/runtime/OpenshiftAiRecorder.java b/openshift-ai/runtime/src/main/java/io/quarkiverse/langchain4j/openshiftai/runtime/OpenshiftAiRecorder.java new file mode 100644 index 000000000..4cbf722d6 --- /dev/null +++ b/openshift-ai/runtime/src/main/java/io/quarkiverse/langchain4j/openshiftai/runtime/OpenshiftAiRecorder.java @@ -0,0 +1,31 @@ +package io.quarkiverse.langchain4j.openshiftai.runtime; + +import java.util.function.Supplier; + +import io.quarkiverse.langchain4j.openshiftai.OpenshiftAiChatModel; +import io.quarkiverse.langchain4j.openshiftai.runtime.config.ChatModelConfig; +import io.quarkiverse.langchain4j.openshiftai.runtime.config.Langchain4jOpenshiftAiConfig; +import io.quarkus.runtime.annotations.Recorder; + +@Recorder +public class OpenshiftAiRecorder { + + public Supplier chatModel(Langchain4jOpenshiftAiConfig runtimeConfig) { + ChatModelConfig chatModelConfig = runtimeConfig.chatModel(); + + var builder = OpenshiftAiChatModel.builder() + .url(runtimeConfig.baseUrl()) + .timeout(runtimeConfig.timeout()) + .logRequests(runtimeConfig.logRequests()) + .logResponses(runtimeConfig.logResponses()) + + .modelId(chatModelConfig.modelId()); + + return new Supplier<>() { + @Override + public Object get() { + return builder.build(); + } + }; + } +} diff --git a/openshift-ai/runtime/src/main/java/io/quarkiverse/langchain4j/openshiftai/runtime/config/ChatModelConfig.java b/openshift-ai/runtime/src/main/java/io/quarkiverse/langchain4j/openshiftai/runtime/config/ChatModelConfig.java new file mode 100644 index 000000000..054404c0a --- /dev/null +++ b/openshift-ai/runtime/src/main/java/io/quarkiverse/langchain4j/openshiftai/runtime/config/ChatModelConfig.java @@ -0,0 +1,12 @@ +package io.quarkiverse.langchain4j.openshiftai.runtime.config; + +import io.quarkus.runtime.annotations.ConfigGroup; + +@ConfigGroup +public interface ChatModelConfig { + + /** + * Model to use + */ + String modelId(); +} diff --git a/openshift-ai/runtime/src/main/java/io/quarkiverse/langchain4j/openshiftai/runtime/config/Langchain4jOpenshiftAiConfig.java b/openshift-ai/runtime/src/main/java/io/quarkiverse/langchain4j/openshiftai/runtime/config/Langchain4jOpenshiftAiConfig.java new file mode 100644 index 000000000..0191ff453 --- /dev/null +++ b/openshift-ai/runtime/src/main/java/io/quarkiverse/langchain4j/openshiftai/runtime/config/Langchain4jOpenshiftAiConfig.java @@ -0,0 +1,44 @@ +package io.quarkiverse.langchain4j.openshiftai.runtime.config; + +import static io.quarkus.runtime.annotations.ConfigPhase.RUN_TIME; + +import java.net.URL; +import java.time.Duration; + +import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; + +@ConfigRoot(phase = RUN_TIME) +@ConfigMapping(prefix = "quarkus.langchain4j.openshift-ai") +public interface Langchain4jOpenshiftAiConfig { + + /** + * Base URL where OpenShift AI serving is running, such as + * {@code https://flant5s-l-predictor-ch2023.apps.cluster-hj2qv.dynamic.redhatworkshops.io:443/api} + */ + URL baseUrl(); + + /** + * Timeout for OpenShift AI calls + */ + @WithDefault("10s") + Duration timeout(); + + /** + * Whether the OpenShift AI client should log requests + */ + @WithDefault("false") + Boolean logRequests(); + + /** + * Whether the OpenShift AI client should log responses + */ + @WithDefault("false") + Boolean logResponses(); + + /** + * Chat model related settings + */ + ChatModelConfig chatModel(); +} diff --git a/openshift-ai/runtime/src/main/resources/META-INF/beans.xml b/openshift-ai/runtime/src/main/resources/META-INF/beans.xml new file mode 100644 index 000000000..e69de29bb diff --git a/openshift-ai/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/openshift-ai/runtime/src/main/resources/META-INF/quarkus-extension.yaml new file mode 100644 index 000000000..760997f2c --- /dev/null +++ b/openshift-ai/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -0,0 +1,11 @@ +name: Quarkus Langchain4j OpenShift AI +artifact: ${project.groupId}:${project.artifactId}:${project.version} +description: Provides integration of Quarkus Langchain4j with the OpenShift AI +metadata: + keywords: + - ai + - langchain4j + - openshift + categories: + - "miscellaneous" + status: "preview" diff --git a/pom.xml b/pom.xml index 957f6944a..c6c46c2f5 100644 --- a/pom.xml +++ b/pom.xml @@ -22,6 +22,7 @@ openai/azure-openai openai/openai-common openai/openai-vanilla + openshift-ai pinecone redis pgvector