Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implementation of API Key Auth #125

Merged
merged 70 commits into from
Jan 21, 2025
Merged
Show file tree
Hide file tree
Changes from 66 commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
8dcdc3a
llmroute to append secret file to extproc deployment for backend secu…
aabchoo Jan 13, 2025
d7b4bed
Merge branch 'main' into aaron/apikey-auth
aabchoo Jan 15, 2025
6dc6415
update auth in filter config for api key
aabchoo Jan 16, 2025
b1a5da4
add backendsecuritypolicy and move extproc deployment to sink
aabchoo Jan 16, 2025
6fbdd05
mount secret file based on backend + backendsecuritypolicies
aabchoo Jan 16, 2025
2d21435
add api key as header
aabchoo Jan 16, 2025
55bd150
add calls to sync extproc deployment
aabchoo Jan 16, 2025
8fe3dd6
Merge remote-tracking branch 'origin/main' into aaron/apikey-auth
aabchoo Jan 16, 2025
9845736
add back accidental change
aabchoo Jan 16, 2025
5456c70
update PR to use indexer func instead of internal cache
aabchoo Jan 17, 2025
15adf8a
Merge remote-tracking branch 'origin/main' into aaron/apikey-auth
aabchoo Jan 17, 2025
f09e082
mount secret file onto extproc deployment
aabchoo Jan 17, 2025
082da92
account for duplicates, already mounted files and files to unmount
aabchoo Jan 17, 2025
40e38d6
add testing for backend security policy controller
aabchoo Jan 17, 2025
40ab582
remove extproc deployment tests
aabchoo Jan 17, 2025
f543c7b
update tests
aabchoo Jan 17, 2025
481647d
create new sink
aabchoo Jan 17, 2025
0136281
export config sink
aabchoo Jan 17, 2025
5ddd4ff
fix tests
aabchoo Jan 17, 2025
2ee446c
fix api key tests
aabchoo Jan 17, 2025
4acddb2
add tests for mount backend security policy
aabchoo Jan 17, 2025
8c65660
add extproc deployment tests
aabchoo Jan 17, 2025
fdffb47
attempt to add api key as part of e2e test
aabchoo Jan 17, 2025
4869fce
Merge remote-tracking branch 'origin/main' into aaron/apikey-auth
aabchoo Jan 17, 2025
f789899
fixing up references
aabchoo Jan 17, 2025
9523364
fixing up tests
aabchoo Jan 17, 2025
68e1d01
update e2e tests
aabchoo Jan 17, 2025
c10d861
add perm to write file
aabchoo Jan 17, 2025
a8eb88d
fix return typing
aabchoo Jan 17, 2025
83e5910
update perm to allow read access
aabchoo Jan 17, 2025
835637a
write file directly for tests
aabchoo Jan 17, 2025
8bfe8fa
remove unnecessary remove
aabchoo Jan 17, 2025
bd44bda
testing file changes
aabchoo Jan 17, 2025
bb3f085
remove header request header to add
aabchoo Jan 17, 2025
4e22818
remove yaml text replace
aabchoo Jan 17, 2025
dd09262
add back def
aabchoo Jan 17, 2025
9e1297e
ci: fixes check out issue in test_e2e with fork (#123)
mathetake Jan 17, 2025
d90c535
Merge remote-tracking branch 'origin/main' into aaron/apikey-auth
aabchoo Jan 17, 2025
93256da
update string needed to be replaced
aabchoo Jan 17, 2025
87b9eb9
update docs and add back code
aabchoo Jan 17, 2025
092aa94
remove sink + deployment check from aiGatewayRoute controller tests
aabchoo Jan 17, 2025
09be4ab
add defaultExtProcImagePullPolicy
aabchoo Jan 17, 2025
6364bae
Update internal/controller/sink.go
aabchoo Jan 19, 2025
e6fd3a4
Update filterconfig/filterconfig.go
aabchoo Jan 19, 2025
4d0704e
Update filterconfig/filterconfig.go
aabchoo Jan 19, 2025
99b5f0d
Update filterconfig/filterconfig.go
aabchoo Jan 19, 2025
41ce7f0
Update internal/extproc/backendauth/api_key.go
aabchoo Jan 20, 2025
e14857c
Update tests/extproc/extproc_test.go
aabchoo Jan 20, 2025
8a19ce3
stop exporting sink and related fields
aabchoo Jan 19, 2025
3145391
add back streaming tests
aabchoo Jan 20, 2025
cf6fac4
variable for spec.containers[0]
aabchoo Jan 20, 2025
be53642
Update internal/controller/sink_test.go
aabchoo Jan 20, 2025
95ad130
indexed func testing
aabchoo Jan 20, 2025
451bfdc
Merge remote-tracking branch 'origin/main' into aaron/auth-apikey
aabchoo Jan 20, 2025
67ac960
Update filterconfig/filterconfig.go
aabchoo Jan 21, 2025
2fbc860
Merge remote-tracking branch 'origin/main' into aaron/auth-apikey
aabchoo Jan 21, 2025
ba790b7
remove unused test
aabchoo Jan 21, 2025
fa08f56
update router logger name
aabchoo Jan 21, 2025
b5e9b08
fixing tests
aabchoo Jan 21, 2025
e13393a
fixing tests
aabchoo Jan 21, 2025
420f212
fix code style
aabchoo Jan 21, 2025
6ac681e
fixing testing
aabchoo Jan 21, 2025
078c92b
code style
aabchoo Jan 21, 2025
514169d
revert t use ai-eg-route-extproc
aabchoo Jan 21, 2025
d122a62
add kind
aabchoo Jan 21, 2025
03d929b
check if values are assigned internally
aabchoo Jan 21, 2025
e08898d
Update internal/controller/ai_service_backend_test.go
aabchoo Jan 21, 2025
3f131ea
Update internal/controller/ai_service_backend_test.go
aabchoo Jan 21, 2025
e2e95cb
Update internal/controller/sink.go
aabchoo Jan 21, 2025
1d151de
Update tests/extproc/extproc_test.go
aabchoo Jan 21, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion filterconfig/filterconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,12 +161,19 @@ type Backend struct {

// BackendAuth ... TODO: refactor after https://github.com/envoyproxy/ai-gateway/pull/43.
type BackendAuth struct {
AWSAuth *AWSAuth `json:"aws,omitempty"`
// APIKey is a location of the api key secret file.
APIKey *APIKeyAuth `json:"apiKey,omitempty"`
AWSAuth *AWSAuth `json:"aws,omitempty"`
}

// AWSAuth ... TODO: refactor after https://github.com/envoyproxy/ai-gateway/pull/43.
type AWSAuth struct{}

// APIKeyAuth defines the file that will be mounted to the external proc.
type APIKeyAuth struct {
Filename string `json:"filename"`
}

// UnmarshalConfigYaml reads the file at the given path and unmarshals it into a Config struct.
func UnmarshalConfigYaml(path string) (*Config, error) {
raw, err := os.ReadFile(path)
Expand Down
118 changes: 11 additions & 107 deletions internal/controller/ai_gateway_route.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/utils/ptr"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
Expand Down Expand Up @@ -43,24 +42,22 @@ func aiGatewayRouteIndexFunc(o client.Object) []string {
//
// This handles the AIGatewayRoute resource and creates the necessary resources for the external process.
type aiGatewayRouteController struct {
client client.Client
kube kubernetes.Interface
logger logr.Logger
defaultExtProcImage string
eventChan chan ConfigSinkEvent
client client.Client
kube kubernetes.Interface
logger logr.Logger
eventChan chan ConfigSinkEvent
}

// NewAIGatewayRouteController creates a new reconcile.TypedReconciler[reconcile.Request] for the AIGatewayRoute resource.
func NewAIGatewayRouteController(
client client.Client, kube kubernetes.Interface, logger logr.Logger,
options Options, ch chan ConfigSinkEvent,
ch chan ConfigSinkEvent,
) reconcile.TypedReconciler[reconcile.Request] {
return &aiGatewayRouteController{
client: client,
kube: kube,
logger: logger.WithName("ai-eg-route-controller"),
defaultExtProcImage: options.ExtProcImage,
eventChan: ch,
client: client,
kube: kube,
logger: logger.WithName("ai-gateway-route-controller"),
eventChan: ch,
}
}

Expand Down Expand Up @@ -97,10 +94,7 @@ func (c *aiGatewayRouteController) Reconcile(ctx context.Context, req reconcile.
logger.Error(err, "Failed to reconcile extProc config map")
return ctrl.Result{}, err
}
if err := c.reconcileExtProcDeployment(ctx, &aiGatewayRoute, ownerRef); err != nil {
logger.Error(err, "Failed to reconcile extProc deployment")
return ctrl.Result{}, err
}

if err := c.reconcileExtProcExtensionPolicy(ctx, &aiGatewayRoute, ownerRef); err != nil {
logger.Error(err, "Failed to reconcile extension policy")
return ctrl.Result{}, err
Expand All @@ -122,6 +116,7 @@ func (c *aiGatewayRouteController) reconcileExtProcExtensionPolicy(ctx context.C
} else if client.IgnoreNotFound(err) != nil {
return fmt.Errorf("failed to get extension policy: %w", err)
}

pm := egv1a1.BufferedExtProcBodyProcessingMode
port := gwapiv1.PortNumber(1063)
objNs := gwapiv1.Namespace(aiGatewayRoute.Namespace)
Expand Down Expand Up @@ -178,97 +173,6 @@ func (c *aiGatewayRouteController) ensuresExtProcConfigMapExists(ctx context.Con
return nil
}

// reconcileExtProcDeployment reconciles the external processor's Deployment and Service.
func (c *aiGatewayRouteController) reconcileExtProcDeployment(ctx context.Context, aiGatewayRoute *aigv1a1.AIGatewayRoute, ownerRef []metav1.OwnerReference) error {
name := extProcName(aiGatewayRoute)
labels := map[string]string{"app": name, managedByLabel: "envoy-ai-gateway"}

deployment, err := c.kube.AppsV1().Deployments(aiGatewayRoute.Namespace).Get(ctx, extProcName(aiGatewayRoute), metav1.GetOptions{})
if err != nil {
if client.IgnoreNotFound(err) == nil {
deployment = &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: aiGatewayRoute.Namespace,
OwnerReferences: ownerRef,
Labels: labels,
},
Spec: appsv1.DeploymentSpec{
Selector: &metav1.LabelSelector{MatchLabels: labels},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: labels},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: name,
Image: c.defaultExtProcImage,
ImagePullPolicy: corev1.PullIfNotPresent,
Ports: []corev1.ContainerPort{{Name: "grpc", ContainerPort: 1063}},
Args: []string{
"-configPath", "/etc/ai-gateway/extproc/" + expProcConfigFileName,
"-logLevel", "info", // TODO: this should be configurable via FilterConfig API.
},
VolumeMounts: []corev1.VolumeMount{
{Name: "config", MountPath: "/etc/ai-gateway/extproc"},
},
},
},
Volumes: []corev1.Volume{
{
Name: "config",
VolumeSource: corev1.VolumeSource{
ConfigMap: &corev1.ConfigMapVolumeSource{
LocalObjectReference: corev1.LocalObjectReference{Name: extProcName(aiGatewayRoute)},
},
},
},
},
},
},
},
}
applyExtProcDeploymentConfigUpdate(&deployment.Spec, aiGatewayRoute.Spec.FilterConfig)
_, err = c.kube.AppsV1().Deployments(aiGatewayRoute.Namespace).Create(ctx, deployment, metav1.CreateOptions{})
if err != nil {
return fmt.Errorf("failed to create deployment: %w", err)
}
c.logger.Info("Created deployment", "name", name)
} else {
return fmt.Errorf("failed to get deployment: %w", err)
}
} else {
applyExtProcDeploymentConfigUpdate(&deployment.Spec, aiGatewayRoute.Spec.FilterConfig)
if _, err = c.kube.AppsV1().Deployments(aiGatewayRoute.Namespace).Update(ctx, deployment, metav1.UpdateOptions{}); err != nil {
return fmt.Errorf("failed to update deployment: %w", err)
}
}

// This is static, so we don't need to update it.
service := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: aiGatewayRoute.Namespace,
OwnerReferences: ownerRef,
Labels: labels,
},
Spec: corev1.ServiceSpec{
Selector: labels,
Ports: []corev1.ServicePort{
{
Name: "grpc",
Protocol: corev1.ProtocolTCP,
Port: 1063,
AppProtocol: ptr.To("grpc"),
},
},
},
}
if _, err = c.kube.CoreV1().Services(aiGatewayRoute.Namespace).Create(ctx, service, metav1.CreateOptions{}); client.IgnoreAlreadyExists(err) != nil {
return fmt.Errorf("failed to create Service %s.%s: %w", name, aiGatewayRoute.Namespace, err)
}
return nil
}

func extProcName(route *aigv1a1.AIGatewayRoute) string {
return fmt.Sprintf("ai-eg-route-extproc-%s", route.Name)
}
Expand Down
51 changes: 0 additions & 51 deletions internal/controller/ai_gateway_route_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,57 +52,6 @@ func TestAIGatewayRouteController_ensuresExtProcConfigMapExists(t *testing.T) {
require.NoError(t, err)
}

func TestAIGatewayRouteController_reconcileExtProcDeployment(t *testing.T) {
c := &aiGatewayRouteController{client: fake.NewClientBuilder().WithScheme(scheme).Build()}
c.kube = fake2.NewClientset()

ownerRef := []metav1.OwnerReference{{APIVersion: "v1", Kind: "Kind", Name: "Name"}}
aiGatewayRoute := &aigv1a1.AIGatewayRoute{
ObjectMeta: metav1.ObjectMeta{Name: "myroute", Namespace: "default"},
Spec: aigv1a1.AIGatewayRouteSpec{
FilterConfig: &aigv1a1.AIGatewayFilterConfig{
Type: aigv1a1.AIGatewayFilterConfigTypeExternalProcess,
ExternalProcess: &aigv1a1.AIGatewayFilterConfigExternalProcess{
Replicas: ptr.To[int32](123),
Resources: &corev1.ResourceRequirements{
Limits: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("200m"),
corev1.ResourceMemory: resource.MustParse("100Mi"),
},
},
},
},
},
}

err := c.reconcileExtProcDeployment(context.Background(), aiGatewayRoute, ownerRef)
require.NoError(t, err)

deployment, err := c.kube.AppsV1().Deployments("default").Get(context.Background(), extProcName(aiGatewayRoute), metav1.GetOptions{})
require.NoError(t, err)
require.Equal(t, extProcName(aiGatewayRoute), deployment.Name)
require.Equal(t, int32(123), *deployment.Spec.Replicas)
require.Equal(t, ownerRef, deployment.OwnerReferences)
require.Equal(t, corev1.ResourceRequirements{
Limits: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("200m"),
corev1.ResourceMemory: resource.MustParse("100Mi"),
},
}, deployment.Spec.Template.Spec.Containers[0].Resources)
service, err := c.kube.CoreV1().Services("default").Get(context.Background(), extProcName(aiGatewayRoute), metav1.GetOptions{})
require.NoError(t, err)
require.Equal(t, extProcName(aiGatewayRoute), service.Name)

// Doing it again should not fail and update the deployment.
aiGatewayRoute.Spec.FilterConfig.ExternalProcess.Replicas = ptr.To[int32](456)
err = c.reconcileExtProcDeployment(context.Background(), aiGatewayRoute, ownerRef)
require.NoError(t, err)
// Check the deployment is updated.
deployment, err = c.kube.AppsV1().Deployments("default").Get(context.Background(), extProcName(aiGatewayRoute), metav1.GetOptions{})
require.NoError(t, err)
require.Equal(t, int32(456), *deployment.Spec.Replicas)
}

func TestAIGatewayRouteController_reconcileExtProcExtensionPolicy(t *testing.T) {
c := &aiGatewayRouteController{client: fake.NewClientBuilder().WithScheme(scheme).Build()}
ownerRef := []metav1.OwnerReference{{APIVersion: "v1", Kind: "Kind", Name: "Name"}}
Expand Down
14 changes: 14 additions & 0 deletions internal/controller/ai_service_backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package controller

import (
"context"
"fmt"

"github.com/go-logr/logr"
"k8s.io/client-go/kubernetes"
Expand All @@ -12,6 +13,10 @@ import (
aigv1a1 "github.com/envoyproxy/ai-gateway/api/v1alpha1"
)

const (
k8sClientIndexBackendSecurityPolicyToReferencingAIServiceBackend = "BackendSecurityPolicyToReferencingAIServiceBackend"
)

// aiBackendController implements [reconcile.TypedReconciler] for [aigv1a1.AIServiceBackend].
//
// This handles the AIServiceBackend resource and sends it to the config sink so that it can modify the configuration together with the state of other resources.
Expand Down Expand Up @@ -47,3 +52,12 @@ func (l *aiBackendController) Reconcile(ctx context.Context, req reconcile.Reque
l.eventChan <- aiBackend.DeepCopy()
return ctrl.Result{}, nil
}

func aiServiceBackendIndexFunc(o client.Object) []string {
mathetake marked this conversation as resolved.
Show resolved Hide resolved
aiServiceBackend := o.(*aigv1a1.AIServiceBackend)
var ret []string
if ref := aiServiceBackend.Spec.BackendSecurityPolicyRef; ref != nil {
ret = append(ret, fmt.Sprintf("%s.%s", ref.Name, aiServiceBackend.Namespace))
}
return ret
}
87 changes: 87 additions & 0 deletions internal/controller/ai_service_backend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@ import (

"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
fake2 "k8s.io/client-go/kubernetes/fake"
"k8s.io/utils/ptr"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
gwapiv1 "sigs.k8s.io/gateway-api/apis/v1"

aigv1a1 "github.com/envoyproxy/ai-gateway/api/v1alpha1"
)
Expand All @@ -30,3 +34,86 @@ func TestAIServiceBackendController_Reconcile(t *testing.T) {
require.Equal(t, "mybackend", item.(*aigv1a1.AIServiceBackend).Name)
require.Equal(t, "default", item.(*aigv1a1.AIServiceBackend).Namespace)
}

func Test_AiServiceBackendIndexFunc(t *testing.T) {
scheme := runtime.NewScheme()
require.NoError(t, aigv1a1.AddToScheme(scheme))

c := fake.NewClientBuilder().
WithScheme(scheme).
WithIndex(&aigv1a1.AIServiceBackend{}, k8sClientIndexBackendSecurityPolicyToReferencingAIServiceBackend, aiServiceBackendIndexFunc).
Build()

// Create Backend Security Policies
aabchoo marked this conversation as resolved.
Show resolved Hide resolved
for _, bsp := range []*aigv1a1.BackendSecurityPolicy{
{
ObjectMeta: metav1.ObjectMeta{Name: "some-backend-security-policy-1", Namespace: "ns"},
Spec: aigv1a1.BackendSecurityPolicySpec{
Type: aigv1a1.BackendSecurityPolicyTypeAPIKey,
APIKey: &aigv1a1.BackendSecurityPolicyAPIKey{
SecretRef: &gwapiv1.SecretObjectReference{Name: "some-secret-policy-1", Namespace: ptr.To[gwapiv1.Namespace]("ns")},
},
},
},
{
ObjectMeta: metav1.ObjectMeta{Name: "some-backend-security-policy-3", Namespace: "ns"},
Spec: aigv1a1.BackendSecurityPolicySpec{
Type: aigv1a1.BackendSecurityPolicyTypeAPIKey,
APIKey: &aigv1a1.BackendSecurityPolicyAPIKey{
SecretRef: &gwapiv1.SecretObjectReference{Name: "some-secret-policy-3", Namespace: ptr.To[gwapiv1.Namespace]("ns")},
},
},
},
} {
require.NoError(t, c.Create(context.Background(), bsp, &client.CreateOptions{}))
}

// Create Ai Service Backends
aabchoo marked this conversation as resolved.
Show resolved Hide resolved
for _, backend := range []*aigv1a1.AIServiceBackend{
{
ObjectMeta: metav1.ObjectMeta{Name: "one", Namespace: "ns"},
Spec: aigv1a1.AIServiceBackendSpec{
BackendRef: gwapiv1.BackendObjectReference{Name: "some-backend1", Namespace: ptr.To[gwapiv1.Namespace]("ns")},
BackendSecurityPolicyRef: &gwapiv1.LocalObjectReference{Name: "some-backend-security-policy-1"},
},
},
{
ObjectMeta: metav1.ObjectMeta{Name: "two", Namespace: "ns"},
Spec: aigv1a1.AIServiceBackendSpec{
BackendRef: gwapiv1.BackendObjectReference{Name: "some-backend2", Namespace: ptr.To[gwapiv1.Namespace]("ns")},
BackendSecurityPolicyRef: &gwapiv1.LocalObjectReference{Name: "some-backend-security-policy-1"},
},
},
{
ObjectMeta: metav1.ObjectMeta{Name: "three", Namespace: "ns"},
Spec: aigv1a1.AIServiceBackendSpec{
BackendRef: gwapiv1.BackendObjectReference{Name: "some-backend3", Namespace: ptr.To[gwapiv1.Namespace]("ns")},
BackendSecurityPolicyRef: &gwapiv1.LocalObjectReference{Name: "some-backend-security-policy-3"},
},
},
{
ObjectMeta: metav1.ObjectMeta{Name: "four", Namespace: "ns"},
Spec: aigv1a1.AIServiceBackendSpec{
BackendRef: gwapiv1.BackendObjectReference{Name: "some-backend4", Namespace: ptr.To[gwapiv1.Namespace]("ns")},
},
},
} {
require.NoError(t, c.Create(context.Background(), backend, &client.CreateOptions{}))
}

var aiServiceBackend aigv1a1.AIServiceBackendList
require.NoError(t, c.List(context.Background(), &aiServiceBackend,
client.MatchingFields{k8sClientIndexBackendSecurityPolicyToReferencingAIServiceBackend: "some-backend-security-policy-1.ns"}))
require.Len(t, aiServiceBackend.Items, 2)
require.Equal(t, "one", aiServiceBackend.Items[0].Name)
require.Equal(t, "two", aiServiceBackend.Items[1].Name)

require.NoError(t, c.List(context.Background(), &aiServiceBackend,
client.MatchingFields{k8sClientIndexBackendSecurityPolicyToReferencingAIServiceBackend: "some-backend-security-policy-2.ns"}))
require.Empty(t, aiServiceBackend.Items)

require.NoError(t, c.List(context.Background(), &aiServiceBackend,
client.MatchingFields{k8sClientIndexBackendSecurityPolicyToReferencingAIServiceBackend: "some-backend-security-policy-3.ns"}))
require.Len(t, aiServiceBackend.Items, 1)
require.Equal(t, "three", aiServiceBackend.Items[0].Name)
}
Loading
Loading