diff --git a/.github/workflows/unit-tests.yaml b/.github/workflows/unit-tests.yaml new file mode 100644 index 00000000..809d6f2f --- /dev/null +++ b/.github/workflows/unit-tests.yaml @@ -0,0 +1,21 @@ +name: Unit Tests + +on: + push: + branches: + - main + pull_request: + +jobs: + unit-tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v6 + + - name: Run tests + run: make test diff --git a/Makefile b/Makefile index 09e511f0..d71eadd4 100644 --- a/Makefile +++ b/Makefile @@ -246,7 +246,7 @@ KUTTL ?= $(LOCALBIN)/kubectl-kuttl ## Tool Versions KUSTOMIZE_VERSION ?= v5.4.2 CONTROLLER_TOOLS_VERSION ?= v0.16.5 -ENVTEST_VERSION ?= release-0.18 +ENVTEST_VERSION ?= release-0.22 GOLANGCI_LINT_VERSION ?= v2.6.0 KUTTL_VERSION ?= 0.22.0 diff --git a/api/v1beta1/openstacklightspeed_types.go b/api/v1beta1/openstacklightspeed_types.go index 6bce34ed..827c4f40 100644 --- a/api/v1beta1/openstacklightspeed_types.go +++ b/api/v1beta1/openstacklightspeed_types.go @@ -155,7 +155,7 @@ type OpenStackLightspeedCore struct { LLMEndpoint string `json:"llmEndpoint"` // +kubebuilder:validation:Required - // +kubebuilder:validation:Enum=azure_openai;bam;openai;watsonx;rhoai_vllm;rhelai_vllm;fake_provider;gemini + // +kubebuilder:validation:Enum=azure_openai;openai;watsonx;rhoai_vllm;rhelai_vllm;gemini // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Provider Type" // Type of the provider serving the LLM LLMEndpointType string `json:"llmEndpointType"` diff --git a/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml b/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml index 73b110a9..8176063e 100644 --- a/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml +++ b/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml @@ -103,12 +103,10 @@ spec: description: Type of the provider serving the LLM enum: - azure_openai - - bam - openai - watsonx - rhoai_vllm - rhelai_vllm - - fake_provider - gemini type: string llmProjectID: diff --git a/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml b/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml index e90ba4d5..2612ab58 100644 --- a/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml +++ b/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml @@ -103,12 +103,10 @@ spec: description: Type of the provider serving the LLM enum: - azure_openai - - bam - openai - watsonx - rhoai_vllm - rhelai_vllm - - fake_provider - gemini type: string llmProjectID: diff --git a/internal/controller/constants.go b/internal/controller/constants.go index 20b74038..b3c4920b 100644 --- a/internal/controller/constants.go +++ b/internal/controller/constants.go @@ -109,8 +109,14 @@ const ( ConsoleProxyAlias = "ols" ConsoleUINetworkPolicyName = "lightspeed-console-plugin" - // Azure - AzureOpenAIType = "azure_openai" + // Provider name constants representing valid values for + // OpenStackLightpseed.Spec.LLMEndpointType (providers available to users) + RHELAIVLLMProviderName = "rhelai_vllm" + RHOAIVLLMProviderName = "rhoai_vllm" + GeminiProviderName = "gemini" + AzureOpenAIProviderName = "azure_openai" + OpenAIProviderName = "openai" + WatsonXProviderName = "watsonx" // EnvVarSuffixAPIKey is the environment variable suffix for API key credentials EnvVarSuffixAPIKey = "_API_KEY" diff --git a/internal/controller/lcore_deployment.go b/internal/controller/lcore_deployment.go index e5ac3bbf..6f8d24d6 100644 --- a/internal/controller/lcore_deployment.go +++ b/internal/controller/lcore_deployment.go @@ -458,7 +458,7 @@ func buildLlamaStackEnvVars(h *common_helper.Helper, ctx context.Context, instan envVarName := providerNameToEnvVarName(provider.Name) - if provider.Type == AzureOpenAIType { + if provider.Type == AzureOpenAIProviderName { // Azure supports both API key and client credentials authentication. // Read the secret to determine which fields are present. secret := &corev1.Secret{} @@ -535,7 +535,7 @@ func buildLlamaStackEnvVars(h *common_helper.Helper, ctx context.Context, instan // For vLLM providers, also set the URL environment variable // The vLLM adapter checks for VLLM_URL as a fallback if URL is not in config - if provider.Type == "rhoai_vllm" || provider.Type == "rhelai_vllm" { + if provider.Type == RHOAIVLLMProviderName || provider.Type == RHELAIVLLMProviderName { if provider.URL != "" { envVars = append(envVars, corev1.EnvVar{ Name: "VLLM_URL", diff --git a/internal/controller/llama_stack_config.go b/internal/controller/llama_stack_config.go index 20e0e4d5..b96bce40 100644 --- a/internal/controller/llama_stack_config.go +++ b/internal/controller/llama_stack_config.go @@ -122,17 +122,17 @@ func buildLlamaStackInferenceProviders(_ *common_helper.Helper, _ context.Contex // Map provider types to Llama Stack provider types switch provider.Type { - case "openai", "gemini", "rhoai_vllm", "rhelai_vllm": + case OpenAIProviderName, GeminiProviderName, RHOAIVLLMProviderName, RHELAIVLLMProviderName: config := map[string]interface{}{} // Determine the appropriate Llama Stack provider type: // - OpenAI uses remote::openai // - vLLM uses remote::vllm var apiKeyField string switch provider.Type { - case "openai": + case OpenAIProviderName: providerConfig["provider_type"] = "remote::openai" apiKeyField = "api_key" - case "gemini": + case GeminiProviderName: providerConfig["provider_type"] = "remote::gemini" apiKeyField = "api_key" default: @@ -149,7 +149,7 @@ func buildLlamaStackInferenceProviders(_ *common_helper.Helper, _ context.Contex providerConfig["config"] = config - case "azure_openai": + case AzureOpenAIProviderName: providerConfig["provider_type"] = "remote::azure" config := map[string]interface{}{} @@ -174,7 +174,17 @@ func buildLlamaStackInferenceProviders(_ *common_helper.Helper, _ context.Contex } providerConfig["config"] = config - case "watsonx", "bam": + case WatsonXProviderName: + providerConfig["provider_type"] = "remote::watsonx" + + config := map[string]interface{}{} + config["base_url"] = provider.URL + config["project_id"] = provider.WatsonProjectID + config["api_key"] = fmt.Sprintf("${env.%s_API_KEY:=}", envVarName) + + providerConfig["config"] = config + + case "bam": // These providers are not supported by Llama Stack // They are handled directly by lightspeed-stack (LCS), not Llama Stack return nil, fmt.Errorf("provider type '%s' (provider '%s') is not currently supported by Llama Stack. Supported types: openai, gemini, azure_openai, rhoai_vllm, rhelai_vllm", provider.Type, provider.Name) diff --git a/internal/controller/llama_stack_config_test.go b/internal/controller/llama_stack_config_test.go new file mode 100644 index 00000000..485f47eb --- /dev/null +++ b/internal/controller/llama_stack_config_test.go @@ -0,0 +1,151 @@ +package controller + +import ( + "context" + "fmt" + + apiv1beta1 "github.com/openstack-lightspeed/operator/api/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func expectSentenceTransformersProvider(providers []interface{}) { + sentenceTransformers := providers[0].(map[string]interface{}) + Expect(sentenceTransformers["provider_id"]).To(Equal("sentence-transformers")) + Expect(sentenceTransformers["provider_type"]).To(Equal("inline::sentence-transformers")) +} + +func getOpenStackLightspeedProvidersInstance(provider string) *apiv1beta1.OpenStackLightspeed { + instance := &apiv1beta1.OpenStackLightspeed{ + ObjectMeta: metav1.ObjectMeta{ + Name: "openstack-lightspeed", + Namespace: "openstack-lightspeed", + }, + } + + switch provider { + case OpenAIProviderName: + instance.Spec.LLMEndpointType = OpenAIProviderName + instance.Spec.LLMEndpoint = "https://api.openai.com/v1" + instance.Spec.ModelName = "gpt-4o" + return instance + case GeminiProviderName: + instance.Spec.LLMEndpointType = GeminiProviderName + instance.Spec.ModelName = "gemini-2.0-flash" + return instance + case RHOAIVLLMProviderName: + instance.Spec.LLMEndpointType = RHOAIVLLMProviderName + instance.Spec.LLMEndpoint = "https://vllm.example.com/v1" + instance.Spec.ModelName = "meta-llama/Llama-3.1-70B-Instruct" + return instance + case RHELAIVLLMProviderName: + instance.Spec.LLMEndpointType = RHELAIVLLMProviderName + instance.Spec.LLMEndpoint = "https://rhelai-vllm.example.com/v1" + instance.Spec.ModelName = "meta-llama/Llama-3.1-70B-Instruct" + return instance + case AzureOpenAIProviderName: + instance.Spec.LLMEndpointType = AzureOpenAIProviderName + instance.Spec.LLMEndpoint = "https://my-resource.openai.azure.com" + instance.Spec.LLMDeploymentName = "gpt-4o-deployment" + instance.Spec.LLMAPIVersion = "2024-02-01" + instance.Spec.ModelName = "gpt-4o" + return instance + case WatsonXProviderName: + instance.Spec.LLMEndpointType = WatsonXProviderName + instance.Spec.LLMEndpoint = "https://watsonx.example.com" + instance.Spec.LLMProjectID = "test-project-id" + instance.Spec.ModelName = "ibm/granite-13b-chat-v2" + return instance + default: + Fail(fmt.Sprintf("Unknown provider %s", provider)) + } + + return nil +} + +func checkModelCommonConfig(modelConfig map[string]interface{}, instance *apiv1beta1.OpenStackLightspeed) { + Expect(modelConfig["model_id"]).To(Equal(instance.Spec.ModelName)) + Expect(modelConfig["model_type"]).To(Equal("llm")) + Expect(modelConfig["provider_id"]).To(Equal(OpenStackLightspeedDefaultProvider)) + Expect(modelConfig["provider_model_id"]).To(Equal(instance.Spec.ModelName)) + Expect(modelConfig).NotTo(HaveKey("metadata")) +} + +var _ = Describe("Llama Stack config", func() { + Describe("buildLlamaStackInferenceProviders", func() { + DescribeTable("should return correct inference providers config", + func(provider, providerType string, checkConfig func(map[string]interface{}, *apiv1beta1.OpenStackLightspeed)) { + instance := getOpenStackLightspeedProvidersInstance(provider) + inferenceProvidersConfig, err := buildLlamaStackInferenceProviders(nil, context.Background(), instance) + + Expect(err).NotTo(HaveOccurred()) + Expect(inferenceProvidersConfig).To(HaveLen(2)) + + expectSentenceTransformersProvider(inferenceProvidersConfig) + + inferenceProvider := inferenceProvidersConfig[1].(map[string]interface{}) + Expect(inferenceProvider["provider_id"]).To(Equal(OpenStackLightspeedDefaultProvider)) + Expect(inferenceProvider["provider_type"]).To(Equal(providerType)) + + checkConfig(inferenceProvider["config"].(map[string]interface{}), instance) + }, + Entry("for openai", OpenAIProviderName, "remote::openai", + func(config map[string]interface{}, _ *apiv1beta1.OpenStackLightspeed) { + Expect(config["api_key"]).To(Equal("${env.OPENSTACK_LIGHTSPEED_PROVIDER_API_KEY}")) + }), + Entry("for gemini", GeminiProviderName, "remote::gemini", + func(config map[string]interface{}, _ *apiv1beta1.OpenStackLightspeed) { + Expect(config["api_key"]).To(Equal("${env.OPENSTACK_LIGHTSPEED_PROVIDER_API_KEY}")) + Expect(config).NotTo(HaveKey("base_url")) + }), + Entry("for rhoai_vllm", RHOAIVLLMProviderName, "remote::vllm", + func(config map[string]interface{}, instance *apiv1beta1.OpenStackLightspeed) { + Expect(config["api_token"]).To(Equal("${env.OPENSTACK_LIGHTSPEED_PROVIDER_API_KEY}")) + Expect(config["base_url"]).To(Equal(instance.Spec.LLMEndpoint)) + }), + Entry("for rhelai_vllm", RHELAIVLLMProviderName, "remote::vllm", + func(config map[string]interface{}, instance *apiv1beta1.OpenStackLightspeed) { + Expect(config["api_token"]).To(Equal("${env.OPENSTACK_LIGHTSPEED_PROVIDER_API_KEY}")) + Expect(config["base_url"]).To(Equal(instance.Spec.LLMEndpoint)) + }), + Entry("for azure_openai", AzureOpenAIProviderName, "remote::azure", + func(config map[string]interface{}, instance *apiv1beta1.OpenStackLightspeed) { + Expect(config["api_key"]).To(Equal("${env.OPENSTACK_LIGHTSPEED_PROVIDER_API_KEY}")) + Expect(config["client_id"]).To(Equal("${env.OPENSTACK_LIGHTSPEED_PROVIDER_CLIENT_ID:=}")) + Expect(config["tenant_id"]).To(Equal("${env.OPENSTACK_LIGHTSPEED_PROVIDER_TENANT_ID:=}")) + Expect(config["client_secret"]).To(Equal("${env.OPENSTACK_LIGHTSPEED_PROVIDER_CLIENT_SECRET:=}")) + Expect(config["api_base"]).To(Equal(instance.Spec.LLMEndpoint)) + Expect(config["deployment_name"]).To(Equal(instance.Spec.LLMDeploymentName)) + Expect(config["api_version"]).To(Equal(instance.Spec.LLMAPIVersion)) + }), + Entry("for watsonx", WatsonXProviderName, "remote::watsonx", + func(config map[string]interface{}, instance *apiv1beta1.OpenStackLightspeed) { + Expect(config["base_url"]).To(Equal(instance.Spec.LLMEndpoint)) + Expect(config["project_id"]).To(Equal(instance.Spec.LLMProjectID)) + Expect(config["api_key"]).To(Equal("${env.OPENSTACK_LIGHTSPEED_PROVIDER_API_KEY:=}")) + }), + ) + }) + + Describe("buildLlamaStackModels", func() { + DescribeTable("should return correct models config", + func(provider string) { + instance := getOpenStackLightspeedProvidersInstance(provider) + modelsConfig := buildLlamaStackModels(nil, instance) + + Expect(modelsConfig).To(HaveLen(1)) + + modelConfig := modelsConfig[0].(map[string]interface{}) + checkModelCommonConfig(modelConfig, instance) + }, + Entry("for openai", OpenAIProviderName), + Entry("for gemini", GeminiProviderName), + Entry("for rhoai_vllm", RHOAIVLLMProviderName), + Entry("for rhelai_vllm", RHELAIVLLMProviderName), + Entry("for azure_openai", AzureOpenAIProviderName), + Entry("for watsonx", WatsonXProviderName), + ) + }) +}) diff --git a/internal/controller/openstacklightspeed_controller_test.go b/internal/controller/openstacklightspeed_controller_test.go index 3bba0b2e..b00b64c1 100644 --- a/internal/controller/openstacklightspeed_controller_test.go +++ b/internal/controller/openstacklightspeed_controller_test.go @@ -54,7 +54,7 @@ var _ = Describe("OpenStackLightspeed Controller", func() { Spec: apiv1beta1.OpenStackLightspeedSpec{ OpenStackLightspeedCore: apiv1beta1.OpenStackLightspeedCore{ LLMEndpoint: "https://example.com/llm", - LLMEndpointType: "openai", + LLMEndpointType: OpenAIProviderName, ModelName: "test-model", }, },